Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/provider/OptimizelyProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('OptimizelyProvider', () => {
});

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

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

unmount();

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

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

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

unmount();

// Store should be reset after unmount
expect(capturedContext!.store.getState().userContext).toBeNull();
// Store state is preserved after unmount — no eager reset.
// The store becomes unreachable to the React tree.
expect(capturedContext!.store.getState().userContext).not.toBeNull();
});

it('should recreate user context when only attributes change (same id)', async () => {
Expand Down
51 changes: 24 additions & 27 deletions src/provider/OptimizelyProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function OptimizelyProvider({
const storeRef = useRef<ProviderStateStore | null>(null);
const userManagerRef = useRef<UserContextManager | null>(null);
const prevClientRef = useRef<Client>();
const hadConfigAtRender = useMemo(() => !!client?.getOptimizelyConfig(), [client]);

if (storeRef.current === null) {
storeRef.current = new ProviderStateStore();
Expand Down Expand Up @@ -70,8 +71,8 @@ export function OptimizelyProvider({
userManagerRef.current.resolveUserContext(user, qualifiedSegments, skipSegments);
}

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

let isMounted = true;

client.onReady({ timeout }).catch((error) => {
if (!isMounted) return;
const err = error instanceof Error ? error : new Error(String(error));
store.setError(err);
});

return () => {
isMounted = false;
};
}, [client, timeout, store]);

// Effect: Subscribe to config/datafile updates (e.g., polling)
useEffect(() => {
if (!client) return;
// When the datafile response is cached (e.g. browser HTTP cache),
// CONFIG_UPDATE may fire before this effect subscribes. In that case
// onReady resolves but CONFIG_UPDATE is never re-emitted (config
// didn't change). The flag lets onReady act as a fallback without
// causing a double-refresh when both fire.
let configReceived = false;

const listenerId = client.notificationCenter.addNotificationListener(
NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE,
() => {
configReceived = true;
store.refresh();
}
);

return () => {
client.notificationCenter.removeNotificationListener(listenerId);
};
}, [client, store]);
client
.onReady({ timeout })
.then(() => {
if (!isMounted || configReceived || hadConfigAtRender) return;
store.refresh();
})
.catch((error) => {
if (!isMounted) return;
const err = error instanceof Error ? error : new Error(String(error));
store.setError(err);
});

// Cleanup on unmount
useEffect(() => {
return () => {
userManagerRef.current?.dispose();
userManagerRef.current = null;
store.reset();
isMounted = false;
client.notificationCenter.removeNotificationListener(listenerId);
};
}, [store]);
}, [client, timeout, store, hadConfigAtRender]);

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