From 252fbd46d23ed1008b4deb264e5481cf78d451fb Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 15 May 2026 17:57:00 +0600 Subject: [PATCH 1/4] [FSSDK-12647] user id vuid adjustment --- src/provider/OptimizelyProvider.spec.tsx | 56 ++++++- src/provider/types.ts | 2 +- src/utils/UserContextManager.spec.ts | 191 +++++++++++++++++++++-- src/utils/UserContextManager.ts | 13 +- src/utils/helpers.spec.ts | 29 ++++ src/utils/helpers.ts | 3 +- 6 files changed, 270 insertions(+), 24 deletions(-) diff --git a/src/provider/OptimizelyProvider.spec.tsx b/src/provider/OptimizelyProvider.spec.tsx index 1e083c2..e7d3746 100644 --- a/src/provider/OptimizelyProvider.spec.tsx +++ b/src/provider/OptimizelyProvider.spec.tsx @@ -233,7 +233,7 @@ describe('OptimizelyProvider', () => { let capturedContext: OptimizelyContextValue | null = null; const { unmount } = render( - + (capturedContext = ctx)} /> ); @@ -488,7 +488,7 @@ describe('OptimizelyProvider', () => { expect(mockClient.createUserContext).toHaveBeenCalledTimes(1); }); - it('should create user context without userId when user prop is not provided', async () => { + it('should not create user context when user prop is not provided', async () => { const mockClient = createMockClient(); render( @@ -497,7 +497,7 @@ describe('OptimizelyProvider', () => { ); - expect(mockClient.createUserContext).toHaveBeenCalledWith(undefined, undefined); + expect(mockClient.createUserContext).not.toHaveBeenCalled(); }); }); @@ -793,6 +793,56 @@ describe('OptimizelyProvider', () => { }); }); + describe('null user', () => { + it('should not create user context when user is null', async () => { + const mockClient = createMockClient(); + + render( + +
Child
+
+ ); + + expect(mockClient.createUserContext).not.toHaveBeenCalled(); + }); + + it('should have null userContext in store when user is null', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().userContext).toBeNull(); + }); + + it('should create context when user changes from null to valid', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + const { rerender } = render( + + (capturedContext = ctx)} /> + + ); + + expect(mockClient.createUserContext).not.toHaveBeenCalled(); + expect(capturedContext!.store.getState().userContext).toBeNull(); + + rerender( + + (capturedContext = ctx)} /> + + ); + + expect(mockClient.createUserContext).toHaveBeenCalledWith('user-1', undefined); + }); + }); + describe('context reference identity', () => { it('should change context value reference when client changes', async () => { const mockClient1 = createMockClient(); diff --git a/src/provider/types.ts b/src/provider/types.ts index c7b5c28..0c02e24 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -38,7 +38,7 @@ export interface OptimizelyProviderProps { /** * User information for decisions. */ - user?: UserInfo; + user?: UserInfo | null; /** * Timeout in milliseconds to wait for the client to become ready. diff --git a/src/utils/UserContextManager.spec.ts b/src/utils/UserContextManager.spec.ts index ff6a638..d401b59 100644 --- a/src/utils/UserContextManager.spec.ts +++ b/src/utils/UserContextManager.spec.ts @@ -126,7 +126,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); // no user + manager.resolveUserContext({}); // empty object = VUID mode await flushPromises(); // Should be waiting on onReady @@ -158,7 +158,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); + manager.resolveUserContext({}); await flushPromises(); expect(client.onReady).not.toHaveBeenCalled(); @@ -239,7 +239,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); // no user + manager.resolveUserContext({}); // empty object = VUID mode await flushPromises(); // Waiting on onReady for VUID @@ -293,7 +293,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(undefined, undefined, true); // no user, skipSegments + manager.resolveUserContext({}, undefined, true); // empty object = VUID, skipSegments await flushPromises(); expect(client.onReady).toHaveBeenCalledTimes(1); @@ -326,7 +326,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(undefined, undefined, true); // no user, no VUID, skipSegments + manager.resolveUserContext({}, undefined, true); // empty object, no VUID, skipSegments await flushPromises(); expect(client.onReady).not.toHaveBeenCalled(); @@ -531,12 +531,12 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - // First call — no userId, will wait for onReady - manager.resolveUserContext(); + // First call — empty object (VUID), will wait for onReady + manager.resolveUserContext({}); await flushPromises(); - // Second call — also no userId, should invalidate first - manager.resolveUserContext(); + // Second call — also empty object, should invalidate first + manager.resolveUserContext({}); await flushPromises(); // Resolve onReady — both resume, but first is stale @@ -564,8 +564,8 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - // First call — no userId, will wait for onReady - manager.resolveUserContext(); + // First call — empty object (VUID), will wait for onReady + manager.resolveUserContext({}); await flushPromises(); expect(client.onReady).toHaveBeenCalled(); @@ -648,7 +648,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); // no userId, will await onReady + manager.resolveUserContext({}); // empty object (VUID), will await onReady await flushPromises(); // Dispose before onReady resolves @@ -701,7 +701,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); // no userId, will await onReady + manager.resolveUserContext({}); // empty object (VUID), will await onReady await flushPromises(); manager.dispose(); @@ -727,7 +727,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); + manager.resolveUserContext({}); await flushPromises(); onReadyDeferred.reject(new Error('SDK init failed')); @@ -747,7 +747,7 @@ describe('UserContextManager', () => { const config = createManagerConfig(client); const manager = new UserContextManager(config); - manager.resolveUserContext(); + manager.resolveUserContext({}); await flushPromises(); onReadyDeferred.reject('string error'); @@ -902,4 +902,165 @@ describe('UserContextManager', () => { manager.dispose(); }); }); + + // ============================================================ + // Null / undefined user (no-context guard) + // ============================================================ + describe('null / undefined user (no-context guard)', () => { + it('should not call createUserContext when user is undefined', async () => { + const { client } = createMockClient({ + hasOdpManager: false, + hasVuidManager: false, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext(undefined); + await flushPromises(); + + expect(client.createUserContext).not.toHaveBeenCalled(); + expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onError).not.toHaveBeenCalled(); + + manager.dispose(); + }); + + it('should not call createUserContext when user is null', async () => { + const { client } = createMockClient({ + hasOdpManager: false, + hasVuidManager: false, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext(null); + await flushPromises(); + + expect(client.createUserContext).not.toHaveBeenCalled(); + expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onError).not.toHaveBeenCalled(); + + manager.dispose(); + }); + + it('should call onUserContextReady(null) when user transitions from valid to null', async () => { + const { client } = createMockClient({ + hasOdpManager: false, + hasVuidManager: false, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext({ id: 'user-1' }); + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(client.createUserContext).toHaveBeenCalledTimes(1); + + manager.resolveUserContext(null); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextReady).toHaveBeenLastCalledWith(null); + expect(client.createUserContext).toHaveBeenCalledTimes(1); + + manager.dispose(); + }); + + it('should cancel in-flight async work when user becomes null', async () => { + const { client, onReadyDeferred } = createMockClient({ + hasOdpManager: false, + hasVuidManager: true, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + // Start with empty object (VUID flow) — async, waiting on onReady + manager.resolveUserContext({}); + await flushPromises(); + expect(client.onReady).toHaveBeenCalled(); + + // User becomes null before onReady resolves + manager.resolveUserContext(null); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextReady).toHaveBeenCalledWith(null); + + // onReady resolves — stale request should not fire callback + onReadyDeferred.resolve(undefined); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(client.createUserContext).not.toHaveBeenCalled(); + + manager.dispose(); + }); + + it('should create context when user transitions from null to valid', async () => { + const { client, mockUserContext } = createMockClient({ + hasOdpManager: false, + hasVuidManager: false, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext(null); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextReady).toHaveBeenCalledWith(null); + + manager.resolveUserContext({ id: 'user-1' }); + + expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined); + expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextReady).toHaveBeenLastCalledWith(mockUserContext); + + manager.dispose(); + }); + + it('should short-circuit when user stays null', async () => { + const { client } = createMockClient({ + hasOdpManager: false, + hasVuidManager: false, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext(null); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + + // Call again with null — should short-circuit + manager.resolveUserContext(null); + await flushPromises(); + + expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(client.createUserContext).not.toHaveBeenCalled(); + + manager.dispose(); + }); + + it('should trigger VUID flow when user is empty object', async () => { + const { client, mockUserContext, onReadyDeferred } = createMockClient({ + hasOdpManager: false, + hasVuidManager: true, + }); + const config = createManagerConfig(client); + const manager = new UserContextManager(config); + + manager.resolveUserContext({}); + await flushPromises(); + + expect(client.onReady).toHaveBeenCalled(); + + onReadyDeferred.resolve(undefined); + await flushPromises(); + + expect(client.createUserContext).toHaveBeenCalledWith(undefined, undefined); + expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + + manager.dispose(); + }); + }); }); diff --git a/src/utils/UserContextManager.ts b/src/utils/UserContextManager.ts index f272c7b..6b9946a 100644 --- a/src/utils/UserContextManager.ts +++ b/src/utils/UserContextManager.ts @@ -22,7 +22,7 @@ import { areSegmentsEqual, areUsersEqual } from './helpers'; export interface UserContextManagerConfig { client: Client; - onUserContextReady: (ctx: OptimizelyUserContext) => void; + onUserContextReady: (ctx: OptimizelyUserContext | null) => void; onError: (error: Error) => void; } @@ -38,7 +38,7 @@ export interface UserContextManagerConfig { */ export class UserContextManager { private readonly client: Client; - private readonly onUserContextReady: (ctx: OptimizelyUserContext) => void; + private readonly onUserContextReady: (ctx: OptimizelyUserContext | null) => void; private readonly onError: (error: Error) => void; private readonly meta: ReactClientMeta; @@ -46,7 +46,7 @@ export class UserContextManager { private disposed = false; private initialized = false; private skipSegments = false; - private prevUser?: UserInfo; + private prevUser?: UserInfo | null; private prevSegments?: string[]; constructor(config: UserContextManagerConfig) { @@ -66,7 +66,7 @@ export class UserContextManager { * @param qualifiedSegments - Optional pre-fetched segments. When provided, * @param skipSegments - Whether to skip ODP segment fetching (default: false) */ - resolveUserContext(user?: UserInfo, qualifiedSegments?: string[], skipSegments = false): void { + resolveUserContext(user?: UserInfo | null, qualifiedSegments?: string[], skipSegments = false): void { if ( this.initialized && this.skipSegments === skipSegments && @@ -83,6 +83,11 @@ export class UserContextManager { const requestId = ++this.requestId; + if (!user) { + this.onUserContextReady(null); + return; + } + this.createUserContext(requestId, user, qualifiedSegments).catch((error: unknown) => { if (this.isStale(requestId)) return; this.onError(error instanceof Error ? error : new Error(String(error))); diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts index 5ea5a1c..28ec6df 100644 --- a/src/utils/helpers.spec.ts +++ b/src/utils/helpers.spec.ts @@ -15,6 +15,35 @@ */ import { describe, it, afterEach, expect, vi } from 'vitest'; import * as utils from './helpers'; +import { areUsersEqual } from './helpers'; + +describe('areUsersEqual', () => { + it('should return true when one is null and other is undefined', () => { + expect(areUsersEqual(null, undefined)).toBe(true); + expect(areUsersEqual(undefined, null)).toBe(true); + }); + + it('should return false when one is null and other is a valid user', () => { + expect(areUsersEqual(null, { id: 'user-1' })).toBe(false); + expect(areUsersEqual({ id: 'user-1' }, null)).toBe(false); + }); + + it('should return true when users have same id and same attributes', () => { + expect( + areUsersEqual({ id: 'user-1', attributes: { plan: 'pro' } }, { id: 'user-1', attributes: { plan: 'pro' } }) + ).toBe(true); + }); + + it('should return false when users have different ids', () => { + expect(areUsersEqual({ id: 'user-1' }, { id: 'user-2' })).toBe(false); + }); + + it('should return false when users have different attributes', () => { + expect( + areUsersEqual({ id: 'user-1', attributes: { plan: 'free' } }, { id: 'user-1', attributes: { plan: 'pro' } }) + ).toBe(false); + }); +}); describe('getQualifiedSegments', () => { const odpIntegration = { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index aa5abb1..532b522 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -35,8 +35,9 @@ export function areSegmentsEqual(a?: string[], b?: string[]): boolean { * Used to prevent redundant user context creation when the user prop * is referentially different but value-equal. */ -export function areUsersEqual(user1?: UserInfo, user2?: UserInfo): boolean { +export function areUsersEqual(user1?: UserInfo | null, user2?: UserInfo | null): boolean { if (user1 === user2) return true; + if (!user1 && !user2) return true; if (!user1 || !user2) return false; if (user1.id !== user2.id) return false; From a8e37c1eb5a55dafc1b1c96e528a369ece47dd86 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 15 May 2026 18:10:02 +0600 Subject: [PATCH 2/4] [FSSDK-12647] doc update --- README.md | 36 ++++++++++++++++++++++++++++++++++-- docs/nextjs-integration.md | 5 ++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d8774b0..4f64b31 100644 --- a/README.md +++ b/README.md @@ -216,12 +216,44 @@ _props_ | Prop | Type | Required | Description | | --- | --- | --- | --- | | `client` | `Client` | Yes | Instance created from `createInstance`. | -| `user` | `{ id?: string; attributes?: UserAttributes }` | No | User info object — `id` and `attributes` will be used to create the user context for all decisions and event tracking. | +| `user` | `{ id?: string; attributes?: UserAttributes } \| null` | No | User info object — `id` and `attributes` will be used to create the user context for all decisions and event tracking. Pass `null`, `undefined`, or omit while user info is being fetched — hooks will return `{ isLoading: true }` until a resolved user is provided. For VUID-only mode (no user ID), pass `user={{}}`. | | `timeout` | `number` | No | Maximum time (in milliseconds) to wait for the SDK to become ready before hooks resolve with a loading state. Default: `30000`. | | `qualifiedSegments` | `string[]` | No | Pre-fetched ODP audience segments for the user. Use [`getQualifiedSegments`](#getqualifiedsegments) to obtain these segments server-side. | | `skipSegments` | `boolean` | No | When `true`, skips background ODP segment fetching. Default: `false`. | -> **Note:** Unless VUID is enabled, `` requires user data. If user information must be fetched asynchronously, resolve the promise before rendering the Provider. +> **Note:** If user information is not yet available, you can render `` without it — hooks will return `{ isLoading: true }` until a resolved user is provided. For VUID-only mode (no user ID), pass `user={{}}`. + +#### VUID-only example + +```jsx +import { + createInstance, + createPollingProjectConfigManager, + createBatchEventProcessor, + createOdpManager, + createVuidManager, + OptimizelyProvider, +} from '@optimizely/react-sdk'; + +const optimizely = createInstance({ + projectConfigManager: createPollingProjectConfigManager({ + sdkKey: 'your-optimizely-sdk-key', + }), + eventProcessor: createBatchEventProcessor(), + odpManager: createOdpManager(), + vuidManager: createVuidManager({ + enableVuid: true, + }), +}); + +function App() { + return ( + + + + ); +} +``` ### Readiness diff --git a/docs/nextjs-integration.md b/docs/nextjs-integration.md index cbad4f7..086db67 100644 --- a/docs/nextjs-integration.md +++ b/docs/nextjs-integration.md @@ -445,12 +445,15 @@ export default function MyFeature() { ### User Promise not supported -User `Promise` is not supported. You must provide a resolved user object to `OptimizelyProvider`. If user information must be fetched asynchronously, resolve the promise before rendering the Provider: +User `Promise` is not supported. You can pass `null`, `undefined`, or omit the `user` prop while user information is being fetched — hooks will return `{ isLoading: true }` until a resolved user object is provided. For VUID-only mode (no user ID), pass `user={{}}`. ```tsx // Supported +// Supported — hooks return { isLoading: true } until user is provided + + // NOT supported ``` From 244b0993da24085e5225271f900495a8913ce81c Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 15 May 2026 18:36:47 +0600 Subject: [PATCH 3/4] [FSSDK-12647] UCM callback name change --- src/provider/OptimizelyProvider.tsx | 2 +- src/utils/UserContextManager.spec.ts | 128 +++++++++++++-------------- src/utils/UserContextManager.ts | 14 +-- 3 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/provider/OptimizelyProvider.tsx b/src/provider/OptimizelyProvider.tsx index e34ef20..922f660 100644 --- a/src/provider/OptimizelyProvider.tsx +++ b/src/provider/OptimizelyProvider.tsx @@ -60,7 +60,7 @@ export function OptimizelyProvider({ userManagerRef.current = new UserContextManager({ client, - onUserContextReady: (ctx) => store.setUserContext(ctx), + onUserContextChange: (ctx) => store.setUserContext(ctx), onError: (error) => store.setError(error), }); diff --git a/src/utils/UserContextManager.spec.ts b/src/utils/UserContextManager.spec.ts index d401b59..7393653 100644 --- a/src/utils/UserContextManager.spec.ts +++ b/src/utils/UserContextManager.spec.ts @@ -72,11 +72,11 @@ function createMockClient(opts: MockClientOptions = {}) { } function createManagerConfig(client: Client) { - const onUserContextReady = vi.fn(); + const onUserContextChange = vi.fn(); const onError = vi.fn(); return { client, - onUserContextReady, + onUserContextChange, onError, }; } @@ -98,7 +98,7 @@ describe('UserContextManager', () => { // ============================================================ describe('ODP not enabled', () => { describe('userId present', () => { - it('should create context synchronously and call onUserContextReady immediately', async () => { + it('should create context synchronously and call onUserContextChange immediately', async () => { const { client, mockUserContext } = createMockClient({ hasOdpManager: false, hasVuidManager: false, @@ -110,7 +110,7 @@ describe('UserContextManager', () => { expect(client.createUserContext).toHaveBeenCalledWith('user-1', { plan: 'premium' }); expect(client.onReady).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -131,14 +131,14 @@ describe('UserContextManager', () => { // Should be waiting on onReady expect(client.onReady).toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); // Resolve onReady (VUID init complete) onReadyDeferred.resolve(undefined); await flushPromises(); expect(client.createUserContext).toHaveBeenCalledWith(undefined, undefined); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -162,7 +162,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.onReady).not.toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); expect(config.onError).toHaveBeenCalledWith(sdkError); manager.dispose(); @@ -192,7 +192,7 @@ describe('UserContextManager', () => { expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined); // Should be waiting on onReady for ODP config expect(client.onReady).toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); // Resolve onReady onReadyDeferred.resolve(undefined); @@ -200,7 +200,7 @@ describe('UserContextManager', () => { expect(client.isOdpIntegrated).toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -223,7 +223,7 @@ describe('UserContextManager', () => { expect(client.isOdpIntegrated).toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); manager.dispose(); }); @@ -244,7 +244,7 @@ describe('UserContextManager', () => { // Waiting on onReady for VUID expect(client.onReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); // Resolve onReady (both VUID and ODP ready) onReadyDeferred.resolve(undefined); @@ -255,7 +255,7 @@ describe('UserContextManager', () => { expect(client.onReady).toHaveBeenCalledTimes(2); expect(client.isOdpIntegrated).toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -263,7 +263,7 @@ describe('UserContextManager', () => { }); describe('userId + skipSegments=true', () => { - it('should create context synchronously and call onUserContextReady immediately', async () => { + it('should create context synchronously and call onUserContextChange immediately', async () => { const { client, mockUserContext } = createMockClient({ hasOdpManager: true, hasVuidManager: false, @@ -276,7 +276,7 @@ describe('UserContextManager', () => { expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined); expect(client.onReady).not.toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -297,7 +297,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.onReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); onReadyDeferred.resolve(undefined); await flushPromises(); @@ -306,7 +306,7 @@ describe('UserContextManager', () => { // Only one onReady call (for VUID), no segment fetch expect(client.onReady).toHaveBeenCalledTimes(1); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -330,7 +330,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.onReady).not.toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); expect(config.onError).toHaveBeenCalledWith(sdkError); manager.dispose(); @@ -343,7 +343,7 @@ describe('UserContextManager', () => { // ============================================================ describe('pre-set qualified segments', () => { describe('qualifiedSegments + skipSegments=true', () => { - it('should set ctx.qualifiedSegments, fire onUserContextReady once, no background fetch', async () => { + it('should set ctx.qualifiedSegments, fire onUserContextChange once, no background fetch', async () => { const { client, mockUserContext } = createMockClient({ hasOdpManager: true, hasVuidManager: false, @@ -355,8 +355,8 @@ describe('UserContextManager', () => { expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined); expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); expect(client.onReady).not.toHaveBeenCalled(); @@ -383,7 +383,7 @@ describe('UserContextManager', () => { // Immediate callback with pre-set segments expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); // Background fetch waiting on onReady expect(client.onReady).toHaveBeenCalled(); @@ -393,7 +393,7 @@ describe('UserContextManager', () => { // Background fetch returned matching segments — no second callback expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -417,14 +417,14 @@ describe('UserContextManager', () => { manager.resolveUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']); // Immediate callback with pre-set segments - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); onReadyDeferred.resolve(undefined); await flushPromises(); // Background fetch returned different segments — second callback fires expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); manager.dispose(); }); @@ -444,7 +444,7 @@ describe('UserContextManager', () => { // Immediate callback with pre-set segments expect(mockUserContext.qualifiedSegments).toEqual(['seg-a']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); onReadyDeferred.resolve(undefined); await flushPromises(); @@ -452,7 +452,7 @@ describe('UserContextManager', () => { // ODP not integrated — no background fetch, no second callback expect(client.isOdpIntegrated).toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -472,7 +472,7 @@ describe('UserContextManager', () => { // Immediate callback with pre-set segments only — no ODP manager, no background fetch expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); expect(client.onReady).not.toHaveBeenCalled(); expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled(); @@ -499,14 +499,14 @@ describe('UserContextManager', () => { // Immediate callback with empty segments expect(mockUserContext.qualifiedSegments).toEqual([]); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); onReadyDeferred.resolve(undefined); await flushPromises(); // Background fetch returned different segments — second callback fires expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); manager.dispose(); }); @@ -545,8 +545,8 @@ describe('UserContextManager', () => { // Only the second request's context should have been reported expect(client.createUserContext).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledWith(expectedCtx); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledWith(expectedCtx); manager.dispose(); }); @@ -573,8 +573,8 @@ describe('UserContextManager', () => { manager.resolveUserContext({ id: 'user-1' }); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledWith(syncCtx); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledWith(syncCtx); // Now resolve onReady — first request should be stale onReadyDeferred.resolve(undefined); @@ -582,7 +582,7 @@ describe('UserContextManager', () => { // Still only one callback (stale request was abandoned) expect(client.createUserContext).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -603,7 +603,7 @@ describe('UserContextManager', () => { manager.resolveUserContext({ id: 'user-1' }, ['seg-a']); // Pre-set segments callback of first request fired - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); // Resolve onReady so background fetch starts onReadyDeferred.resolve(undefined); @@ -621,7 +621,7 @@ describe('UserContextManager', () => { manager.resolveUserContext({ id: 'user-2' }); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); // First request's background fetch completes — callback should be suppressed (stale) (mockUserContext as unknown as { qualifiedSegments: string[] }).qualifiedSegments = ['seg-a', 'seg-new']; @@ -630,7 +630,7 @@ describe('UserContextManager', () => { await flushPromises(); // Still only 2 calls — background fetch callback of stale request was suppressed - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); manager.dispose(); }); @@ -658,7 +658,7 @@ describe('UserContextManager', () => { onReadyDeferred.resolve(undefined); await flushPromises(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); expect(config.onError).not.toHaveBeenCalled(); }); @@ -681,7 +681,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); // Dispose while segments are being fetched manager.dispose(); @@ -690,7 +690,7 @@ describe('UserContextManager', () => { segmentDeferred.resolve(true); await flushPromises(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); }); it('should suppress error callbacks after dispose', async () => { @@ -711,7 +711,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(config.onError).not.toHaveBeenCalled(); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); }); }); @@ -734,7 +734,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(config.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'SDK init failed' })); - expect(config.onUserContextReady).not.toHaveBeenCalled(); + expect(config.onUserContextChange).not.toHaveBeenCalled(); manager.dispose(); }); @@ -774,13 +774,13 @@ describe('UserContextManager', () => { manager.resolveUserContext({ id: 'user-1', attributes: { plan: 'pro' } }); expect(client.createUserContext).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); // Call again with value-equal user — should be a no-op manager.resolveUserContext({ id: 'user-1', attributes: { plan: 'pro' } }); expect(client.createUserContext).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -835,10 +835,10 @@ describe('UserContextManager', () => { const manager = new UserContextManager(config); manager.resolveUserContext({ id: 'user-1' }, ['seg-a']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.resolveUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); manager.dispose(); }); @@ -859,11 +859,11 @@ describe('UserContextManager', () => { const manager = new UserContextManager(config); manager.resolveUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); // New array reference, same values manager.resolveUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); manager.dispose(); }); @@ -919,7 +919,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.createUserContext).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onUserContextChange).toHaveBeenCalledWith(null); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); @@ -937,13 +937,13 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.createUserContext).not.toHaveBeenCalled(); - expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onUserContextChange).toHaveBeenCalledWith(null); expect(config.onError).not.toHaveBeenCalled(); manager.dispose(); }); - it('should call onUserContextReady(null) when user transitions from valid to null', async () => { + it('should call onUserContextChange(null) when user transitions from valid to null', async () => { const { client } = createMockClient({ hasOdpManager: false, hasVuidManager: false, @@ -952,14 +952,14 @@ describe('UserContextManager', () => { const manager = new UserContextManager(config); manager.resolveUserContext({ id: 'user-1' }); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); expect(client.createUserContext).toHaveBeenCalledTimes(1); manager.resolveUserContext(null); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); - expect(config.onUserContextReady).toHaveBeenLastCalledWith(null); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenLastCalledWith(null); expect(client.createUserContext).toHaveBeenCalledTimes(1); manager.dispose(); @@ -982,14 +982,14 @@ describe('UserContextManager', () => { manager.resolveUserContext(null); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledWith(null); // onReady resolves — stale request should not fire callback onReadyDeferred.resolve(undefined); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); expect(client.createUserContext).not.toHaveBeenCalled(); manager.dispose(); @@ -1006,14 +1006,14 @@ describe('UserContextManager', () => { manager.resolveUserContext(null); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); - expect(config.onUserContextReady).toHaveBeenCalledWith(null); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledWith(null); manager.resolveUserContext({ id: 'user-1' }); expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined); - expect(config.onUserContextReady).toHaveBeenCalledTimes(2); - expect(config.onUserContextReady).toHaveBeenLastCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledTimes(2); + expect(config.onUserContextChange).toHaveBeenLastCalledWith(mockUserContext); manager.dispose(); }); @@ -1029,13 +1029,13 @@ describe('UserContextManager', () => { manager.resolveUserContext(null); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); // Call again with null — should short-circuit manager.resolveUserContext(null); await flushPromises(); - expect(config.onUserContextReady).toHaveBeenCalledTimes(1); + expect(config.onUserContextChange).toHaveBeenCalledTimes(1); expect(client.createUserContext).not.toHaveBeenCalled(); manager.dispose(); @@ -1058,7 +1058,7 @@ describe('UserContextManager', () => { await flushPromises(); expect(client.createUserContext).toHaveBeenCalledWith(undefined, undefined); - expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext); + expect(config.onUserContextChange).toHaveBeenCalledWith(mockUserContext); manager.dispose(); }); diff --git a/src/utils/UserContextManager.ts b/src/utils/UserContextManager.ts index 6b9946a..69971bd 100644 --- a/src/utils/UserContextManager.ts +++ b/src/utils/UserContextManager.ts @@ -22,7 +22,7 @@ import { areSegmentsEqual, areUsersEqual } from './helpers'; export interface UserContextManagerConfig { client: Client; - onUserContextReady: (ctx: OptimizelyUserContext | null) => void; + onUserContextChange: (ctx: OptimizelyUserContext | null) => void; onError: (error: Error) => void; } @@ -38,7 +38,7 @@ export interface UserContextManagerConfig { */ export class UserContextManager { private readonly client: Client; - private readonly onUserContextReady: (ctx: OptimizelyUserContext | null) => void; + private readonly onUserContextChange: (ctx: OptimizelyUserContext | null) => void; private readonly onError: (error: Error) => void; private readonly meta: ReactClientMeta; @@ -51,7 +51,7 @@ export class UserContextManager { constructor(config: UserContextManagerConfig) { this.client = config.client; - this.onUserContextReady = config.onUserContextReady; + this.onUserContextChange = config.onUserContextChange; this.onError = config.onError; this.meta = (this.client as unknown as Record)[REACT_CLIENT_META]; @@ -84,7 +84,7 @@ export class UserContextManager { const requestId = ++this.requestId; if (!user) { - this.onUserContextReady(null); + this.onUserContextChange(null); return; } @@ -112,7 +112,7 @@ export class UserContextManager { if (qualifiedSegments !== undefined) { ctx.qualifiedSegments = qualifiedSegments; - this.onUserContextReady(ctx); // immediate callback for sync decision with pre-set segments + this.onUserContextChange(ctx); // immediate callback for sync decision with pre-set segments if (this.skipSegments) return; @@ -131,7 +131,7 @@ export class UserContextManager { // update only if different if (!areSegmentsEqual(snapshot, ctx.qualifiedSegments)) { - this.onUserContextReady(ctx); + this.onUserContextChange(ctx); } } } @@ -149,7 +149,7 @@ export class UserContextManager { } } - this.onUserContextReady(ctx); + this.onUserContextChange(ctx); } private isStale(requestId: number): boolean { From 3127dccf13f8e225afbe78cb84b6502cafef7c30 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 15 May 2026 20:06:25 +0600 Subject: [PATCH 4/4] [FSSDK-12647] review feedback --- src/provider/OptimizelyProvider.spec.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/provider/OptimizelyProvider.spec.tsx b/src/provider/OptimizelyProvider.spec.tsx index e7d3746..29b053e 100644 --- a/src/provider/OptimizelyProvider.spec.tsx +++ b/src/provider/OptimizelyProvider.spec.tsx @@ -840,6 +840,28 @@ describe('OptimizelyProvider', () => { ); expect(mockClient.createUserContext).toHaveBeenCalledWith('user-1', undefined); + expect(capturedContext!.store.getState().userContext).not.toBeNull(); + }); + + it('should set store userContext to null when user changes from valid to null', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + const { rerender } = render( + + (capturedContext = ctx)} /> + + ); + + expect(capturedContext!.store.getState().userContext).not.toBeNull(); + + rerender( + + (capturedContext = ctx)} /> + + ); + + expect(capturedContext!.store.getState().userContext).toBeNull(); }); });