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();
});
});