Skip to content

Commit 1a05d4f

Browse files
Merge branch 'master' into junaed/fssdk-12455-doc-update
2 parents a0a5119 + bf58c93 commit 1a05d4f

2 files changed

Lines changed: 119 additions & 49 deletions

File tree

src/provider/OptimizelyProvider.spec.tsx

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('OptimizelyProvider', () => {
228228
});
229229

230230
describe('cleanup', () => {
231-
it('should reset store on unmount', async () => {
231+
it('should not reset store on unmount (store becomes unreachable to React tree)', async () => {
232232
const mockClient = createMockClient();
233233
let capturedContext: OptimizelyContextValue | null = null;
234234

@@ -246,8 +246,11 @@ describe('OptimizelyProvider', () => {
246246

247247
unmount();
248248

249-
// Store should be reset
250-
expect(store.getState().userContext).toBeNull();
249+
// Store state is preserved — on real unmount, the store becomes
250+
// unreachable to the React tree. Eagerly resetting breaks React
251+
// 18+ StrictMode (effect cleanup destroys state that the render
252+
// body set, and no re-render restores it).
253+
expect(store.getState().userContext).not.toBeNull();
251254
expect(store.getState().error).toBeNull();
252255
});
253256
});
@@ -391,7 +394,7 @@ describe('OptimizelyProvider', () => {
391394
expect(mockClient2.createUserContext).toHaveBeenCalledWith('user-1', undefined);
392395
});
393396

394-
it('should dispose manager on unmount', async () => {
397+
it('should preserve store state on unmount (no eager reset)', async () => {
395398
const mockClient = createMockClient();
396399
let capturedContext: OptimizelyContextValue | null = null;
397400

@@ -406,8 +409,9 @@ describe('OptimizelyProvider', () => {
406409

407410
unmount();
408411

409-
// Store should be reset after unmount
410-
expect(capturedContext!.store.getState().userContext).toBeNull();
412+
// Store state is preserved after unmount — no eager reset.
413+
// The store becomes unreachable to the React tree.
414+
expect(capturedContext!.store.getState().userContext).not.toBeNull();
411415
});
412416

413417
it('should recreate user context when only attributes change (same id)', async () => {
@@ -634,7 +638,7 @@ describe('OptimizelyProvider', () => {
634638
});
635639

636640
describe('config update subscription', () => {
637-
it('should subscribe to OPTIMIZELY_CONFIG_UPDATE on mount', () => {
641+
it('should subscribe to OPTIMIZELY_CONFIG_UPDATE after onReady', async () => {
638642
const mockClient = createMockClient();
639643

640644
render(
@@ -643,13 +647,15 @@ describe('OptimizelyProvider', () => {
643647
</OptimizelyProvider>
644648
);
645649

646-
expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledWith(
647-
'OPTIMIZELY_CONFIG_UPDATE',
648-
expect.any(Function)
649-
);
650+
await waitFor(() => {
651+
expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledWith(
652+
'OPTIMIZELY_CONFIG_UPDATE',
653+
expect.any(Function)
654+
);
655+
});
650656
});
651657

652-
it('should remove notification listener on unmount', () => {
658+
it('should remove notification listener on unmount', async () => {
653659
const mockClient = createMockClient();
654660

655661
const { unmount } = render(
@@ -658,6 +664,10 @@ describe('OptimizelyProvider', () => {
658664
</OptimizelyProvider>
659665
);
660666

667+
await waitFor(() => {
668+
expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
669+
});
670+
661671
unmount();
662672

663673
expect(mockClient.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
@@ -675,6 +685,7 @@ describe('OptimizelyProvider', () => {
675685

676686
await waitFor(() => {
677687
expect(capturedContext).not.toBeNull();
688+
expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
678689
});
679690

680691
const stateBefore = capturedContext!.store.getState();
@@ -694,7 +705,7 @@ describe('OptimizelyProvider', () => {
694705
expect(stateBefore).not.toBe(stateAfter);
695706
});
696707

697-
it('should re-subscribe when client changes', () => {
708+
it('should re-subscribe when client changes', async () => {
698709
const mockClient1 = createMockClient();
699710
const mockClient2 = createMockClient();
700711

@@ -704,17 +715,81 @@ describe('OptimizelyProvider', () => {
704715
</OptimizelyProvider>
705716
);
706717

707-
expect(mockClient1.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
718+
await waitFor(() => {
719+
expect(mockClient1.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
720+
});
708721

709722
rerender(
710723
<OptimizelyProvider client={mockClient2}>
711724
<div>Child</div>
712725
</OptimizelyProvider>
713726
);
714727

715-
// Old listener cleaned up, new one registered
728+
await waitFor(() => {
729+
expect(mockClient2.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
730+
});
731+
732+
// Old listener cleaned up
716733
expect(mockClient1.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
717-
expect(mockClient2.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
734+
});
735+
});
736+
737+
describe('config revision race condition', () => {
738+
it('should refresh when config revision changes between render and onReady', async () => {
739+
let resolveOnReady: () => void;
740+
const onReadyPromise = new Promise<void>((resolve) => {
741+
resolveOnReady = resolve;
742+
});
743+
744+
const mockClient = createMockClient({
745+
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
746+
onReady: vi.fn().mockReturnValue(onReadyPromise),
747+
});
748+
let capturedContext: OptimizelyContextValue | null = null;
749+
750+
render(
751+
<OptimizelyProvider client={mockClient}>
752+
<ContextConsumer onContext={(ctx) => (capturedContext = ctx)} />
753+
</OptimizelyProvider>
754+
);
755+
756+
expect(capturedContext).not.toBeNull();
757+
const stateBefore = capturedContext!.store.getState();
758+
759+
// Config updates to v2 before onReady resolves
760+
(mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '2' });
761+
762+
await act(async () => {
763+
resolveOnReady!();
764+
});
765+
766+
const stateAfter = capturedContext!.store.getState();
767+
expect(stateBefore).not.toBe(stateAfter);
768+
});
769+
770+
it('should not refresh when config revision is unchanged at onReady', async () => {
771+
const mockClient = createMockClient({
772+
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
773+
});
774+
let capturedContext: OptimizelyContextValue | null = null;
775+
776+
render(
777+
<OptimizelyProvider client={mockClient}>
778+
<ContextConsumer onContext={(ctx) => (capturedContext = ctx)} />
779+
</OptimizelyProvider>
780+
);
781+
782+
await waitFor(() => {
783+
expect(capturedContext).not.toBeNull();
784+
});
785+
786+
const stateBefore = capturedContext!.store.getState();
787+
788+
// Flush onReady microtask — revision is still '1'
789+
await act(async () => {});
790+
791+
const stateAfter = capturedContext!.store.getState();
792+
expect(stateBefore).toBe(stateAfter);
718793
});
719794
});
720795

src/provider/OptimizelyProvider.tsx

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function OptimizelyProvider({
3838
const storeRef = useRef<ProviderStateStore | null>(null);
3939
const userManagerRef = useRef<UserContextManager | null>(null);
4040
const prevClientRef = useRef<Client>();
41+
const revisionAtRender = useMemo(() => client?.getOptimizelyConfig()?.revision, [client]);
4142

4243
if (storeRef.current === null) {
4344
storeRef.current = new ProviderStateStore();
@@ -70,8 +71,8 @@ export function OptimizelyProvider({
7071
userManagerRef.current.resolveUserContext(user, qualifiedSegments, skipSegments);
7172
}
7273

73-
// Effect: Client onReady — only needed for error handling.
74-
// Readiness is derived from userContext + getOptimizelyConfig() by hooks.
74+
// Effect: Client readiness + config update subscription.
75+
// Handles both initial datafile fetch and subsequent polling updates.
7576
useEffect(() => {
7677
if (!client) {
7778
console.error('[OPTIMIZELY - REACT] OptimizelyProvider must be passed an Optimizely client instance');
@@ -80,42 +81,36 @@ export function OptimizelyProvider({
8081
}
8182

8283
let isMounted = true;
83-
84-
client.onReady({ timeout }).catch((error) => {
85-
if (!isMounted) return;
86-
const err = error instanceof Error ? error : new Error(String(error));
87-
store.setError(err);
88-
});
84+
let listenerId: number | undefined;
85+
86+
client
87+
.onReady({ timeout })
88+
.then(() => {
89+
if (!isMounted) return;
90+
91+
const currentRevision = client.getOptimizelyConfig()?.revision;
92+
if (currentRevision !== revisionAtRender) {
93+
store.refresh();
94+
}
95+
96+
listenerId = client.notificationCenter.addNotificationListener(
97+
NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
98+
() => store.refresh()
99+
);
100+
})
101+
.catch((error) => {
102+
if (!isMounted) return;
103+
const err = error instanceof Error ? error : new Error(String(error));
104+
store.setError(err);
105+
});
89106

90107
return () => {
91108
isMounted = false;
92-
};
93-
}, [client, timeout, store]);
94-
95-
// Effect: Subscribe to config/datafile updates (e.g., polling)
96-
useEffect(() => {
97-
if (!client) return;
98-
99-
const listenerId = client.notificationCenter.addNotificationListener(
100-
NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
101-
() => {
102-
store.refresh();
109+
if (listenerId !== undefined) {
110+
client.notificationCenter.removeNotificationListener(listenerId);
103111
}
104-
);
105-
106-
return () => {
107-
client.notificationCenter.removeNotificationListener(listenerId);
108-
};
109-
}, [client, store]);
110-
111-
// Cleanup on unmount
112-
useEffect(() => {
113-
return () => {
114-
userManagerRef.current?.dispose();
115-
userManagerRef.current = null;
116-
store.reset();
117112
};
118-
}, [store]);
113+
}, [client, timeout, store, revisionAtRender]);
119114

120115
return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>;
121116
}

0 commit comments

Comments
 (0)