Skip to content

Commit a3e621b

Browse files
[fssdk-12294] provider update for hooks
1 parent 0659716 commit a3e621b

3 files changed

Lines changed: 184 additions & 3 deletions

File tree

src/hooks/useDecide.spec.tsx

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

1717
import { vi, describe, it, expect, beforeEach } from 'vitest';
1818
import React from 'react';
19-
import { act } from '@testing-library/react';
19+
import { act, waitFor } from '@testing-library/react';
2020
import { renderHook } from '@testing-library/react';
21-
import { OptimizelyContext, ProviderStateStore } from '../provider/index';
21+
import { OptimizelyContext, ProviderStateStore, OptimizelyProvider } from '../provider/index';
22+
import { REACT_CLIENT_META } from '../client/index';
2223
import { useDecide } from './useDecide';
2324
import type {
2425
OptimizelyUserContext,
@@ -68,6 +69,49 @@ function createMockClient(hasConfig = false): Client {
6869
} as unknown as Client;
6970
}
7071

72+
/**
73+
* Creates a mock client with notification center support and wraps it in OptimizelyProvider.
74+
* Used for integration-style tests that need the full Provider lifecycle.
75+
*/
76+
function createProviderWrapper(mockUserContext: OptimizelyUserContext) {
77+
let configUpdateCallback: (() => void) | undefined;
78+
79+
const client = {
80+
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
81+
createUserContext: vi.fn().mockReturnValue(mockUserContext),
82+
onReady: vi.fn().mockResolvedValue(undefined),
83+
isOdpIntegrated: vi.fn().mockReturnValue(false),
84+
notificationCenter: {
85+
addNotificationListener: vi.fn().mockImplementation((type: string, cb: () => void) => {
86+
if (type === 'OPTIMIZELY_CONFIG_UPDATE') {
87+
configUpdateCallback = cb;
88+
}
89+
return 1;
90+
}),
91+
removeNotificationListener: vi.fn(),
92+
},
93+
} as unknown as Client;
94+
95+
(client as unknown as Record<symbol, unknown>)[REACT_CLIENT_META] = {
96+
hasOdpManager: false,
97+
hasVuidManager: false,
98+
};
99+
100+
function Wrapper({ children }: { children: React.ReactNode }) {
101+
return (
102+
<OptimizelyProvider client={client} user={{ id: 'user-1' }}>
103+
{children}
104+
</OptimizelyProvider>
105+
);
106+
}
107+
108+
return {
109+
wrapper: Wrapper,
110+
client,
111+
fireConfigUpdate: () => configUpdateCallback?.(),
112+
};
113+
}
114+
71115
function createWrapper(store: ProviderStateStore, client: Client) {
72116
const contextValue: OptimizelyContextValue = { store, client };
73117

@@ -346,6 +390,38 @@ describe('useDecide', () => {
346390
expect(result.current.decision).toBe(MOCK_DECISION);
347391
});
348392

393+
it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => {
394+
const mockUserContext = createMockUserContext();
395+
const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext);
396+
397+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
398+
399+
// Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete
400+
await waitFor(() => {
401+
expect(result.current.isLoading).toBe(false);
402+
});
403+
404+
expect(result.current.decision).toBe(MOCK_DECISION);
405+
const callCountBeforeUpdate = (mockUserContext.decide as ReturnType<typeof vi.fn>).mock.calls.length;
406+
407+
// Simulate a new datafile with a different decision
408+
const updatedDecision: OptimizelyDecision = {
409+
...MOCK_DECISION,
410+
variationKey: 'variation_2',
411+
variables: { color: 'blue' },
412+
};
413+
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockReturnValue(updatedDecision);
414+
415+
// Fire the config update notification (as the SDK would on datafile poll)
416+
await act(async () => {
417+
fireConfigUpdate();
418+
});
419+
420+
expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1);
421+
expect(result.current.decision).toBe(updatedDecision);
422+
expect(result.current.isLoading).toBe(false);
423+
});
424+
349425
describe('forced decision reactivity', () => {
350426
it('should re-evaluate when setForcedDecision is called for the same flagKey', () => {
351427
mockClient = createMockClient(true);

src/provider/OptimizelyProvider.spec.tsx

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ function createMockClient(
5555
createUserContext: vi.fn().mockReturnValue(mockUserContext),
5656
close: vi.fn(),
5757
getOptimizelyConfig: vi.fn(),
58-
notificationCenter: {} as OptimizelyClient['notificationCenter'],
58+
notificationCenter: {
59+
addNotificationListener: vi.fn().mockReturnValue(1),
60+
removeNotificationListener: vi.fn(),
61+
} as unknown as OptimizelyClient['notificationCenter'],
5962
sendOdpEvent: vi.fn(),
6063
isOdpIntegrated: vi.fn().mockReturnValue(false),
6164
...overrides,
@@ -638,6 +641,91 @@ describe('OptimizelyProvider', () => {
638641
});
639642
});
640643

644+
describe('config update subscription', () => {
645+
it('should subscribe to OPTIMIZELY_CONFIG_UPDATE on mount', () => {
646+
const mockClient = createMockClient();
647+
648+
render(
649+
<OptimizelyProvider client={mockClient}>
650+
<div>Child</div>
651+
</OptimizelyProvider>
652+
);
653+
654+
expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledWith(
655+
'OPTIMIZELY_CONFIG_UPDATE',
656+
expect.any(Function)
657+
);
658+
});
659+
660+
it('should remove notification listener on unmount', () => {
661+
const mockClient = createMockClient();
662+
663+
const { unmount } = render(
664+
<OptimizelyProvider client={mockClient}>
665+
<div>Child</div>
666+
</OptimizelyProvider>
667+
);
668+
669+
unmount();
670+
671+
expect(mockClient.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
672+
});
673+
674+
it('should trigger store state change when config update fires', async () => {
675+
const mockClient = createMockClient();
676+
let capturedContext: OptimizelyContextValue | null = null;
677+
678+
render(
679+
<OptimizelyProvider client={mockClient}>
680+
<ContextConsumer onContext={(ctx) => (capturedContext = ctx)} />
681+
</OptimizelyProvider>
682+
);
683+
684+
await waitFor(() => {
685+
expect(capturedContext).not.toBeNull();
686+
});
687+
688+
const stateBefore = capturedContext!.store.getState();
689+
690+
// Get the callback that was registered and invoke it
691+
const configUpdateCallback = (
692+
mockClient.notificationCenter.addNotificationListener as ReturnType<typeof vi.fn>
693+
).mock.calls.find((call: unknown[]) => call[0] === 'OPTIMIZELY_CONFIG_UPDATE')![1];
694+
695+
await act(() => {
696+
configUpdateCallback();
697+
});
698+
699+
const stateAfter = capturedContext!.store.getState();
700+
701+
// State should be a new reference (triggers useSyncExternalStore subscribers)
702+
expect(stateBefore).not.toBe(stateAfter);
703+
});
704+
705+
it('should re-subscribe when client changes', () => {
706+
const mockClient1 = createMockClient();
707+
const mockClient2 = createMockClient();
708+
709+
const { rerender } = render(
710+
<OptimizelyProvider client={mockClient1}>
711+
<div>Child</div>
712+
</OptimizelyProvider>
713+
);
714+
715+
expect(mockClient1.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
716+
717+
rerender(
718+
<OptimizelyProvider client={mockClient2}>
719+
<div>Child</div>
720+
</OptimizelyProvider>
721+
);
722+
723+
// Old listener cleaned up, new one registered
724+
expect(mockClient1.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
725+
expect(mockClient2.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
726+
});
727+
});
728+
641729
describe('context reference identity', () => {
642730
it('should change context value reference when client changes', async () => {
643731
const mockClient1 = createMockClient();

src/provider/OptimizelyProvider.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import React, { createContext, useRef, useMemo, useEffect } from 'react';
18+
import { NOTIFICATION_TYPES } from '@optimizely/optimizely-sdk';
1819

1920
import { ProviderStateStore } from './ProviderStateStore';
2021
import { UserContextManager } from '../utils/UserContextManager';
@@ -110,6 +111,22 @@ export function OptimizelyProvider({
110111
};
111112
}, [client, timeout, store]);
112113

114+
// Effect: Subscribe to config/datafile updates (e.g., polling)
115+
useEffect(() => {
116+
if (!client) return;
117+
118+
const listenerId = client.notificationCenter.addNotificationListener(
119+
NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
120+
() => {
121+
store.setState({});
122+
}
123+
);
124+
125+
return () => {
126+
client.notificationCenter.removeNotificationListener(listenerId);
127+
};
128+
}, [client, store]);
129+
113130
// Cleanup on unmount
114131
useEffect(() => {
115132
return () => {

0 commit comments

Comments
 (0)