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
3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ export { useOptimizelyUserContext } from './useOptimizelyUserContext';
export type { UseOptimizelyUserContextResult } from './useOptimizelyUserContext';
export { useDecide } from './useDecide';
export type { UseDecideConfig, UseDecideResult } from './useDecide';
export { useDecideForKeys } from './useDecideForKeys';
export type { UseDecideMultiResult } from './useDecideForKeys';
export { useDecideAll } from './useDecideAll';
148 changes: 148 additions & 0 deletions src/hooks/testUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Copyright 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { vi } from 'vitest';
import React from 'react';
import { OptimizelyContext, ProviderStateStore, OptimizelyProvider } from '../provider/index';
import { REACT_CLIENT_META } from '../client/index';
import type { OptimizelyUserContext, OptimizelyDecision, Client } from '@optimizely/optimizely-sdk';
import type { OptimizelyContextValue } from '../provider/index';

export const MOCK_DECISION: OptimizelyDecision = {
variationKey: 'variation_1',
enabled: true,
variables: { color: 'red' },
ruleKey: 'rule_1',
flagKey: 'flag_1',
userContext: {} as OptimizelyUserContext,
reasons: [],
};

export const MOCK_DECISIONS: Record<string, OptimizelyDecision> = {
flag_1: MOCK_DECISION,
flag_2: {
variationKey: 'variation_2',
enabled: false,
variables: { size: 'large' },
ruleKey: 'rule_2',
flagKey: 'flag_2',
userContext: {} as OptimizelyUserContext,
reasons: [],
},
};

/**
* Creates a mock OptimizelyUserContext with all methods stubbed.
* Override specific methods via the overrides parameter.
*/
export function createMockUserContext(
overrides?: Partial<Record<string, unknown>>,
): OptimizelyUserContext {
return {
getUserId: vi.fn().mockReturnValue('test-user'),
getAttributes: vi.fn().mockReturnValue({}),
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
decide: vi.fn().mockReturnValue(MOCK_DECISION),
decideAll: vi.fn().mockReturnValue(MOCK_DECISIONS),
decideForKeys: vi.fn().mockImplementation((keys: string[]) => {
const result: Record<string, OptimizelyDecision> = {};
for (const key of keys) {
if (MOCK_DECISIONS[key]) {
result[key] = MOCK_DECISIONS[key];
}
}
return result;
}),
setForcedDecision: vi.fn().mockReturnValue(true),
getForcedDecision: vi.fn(),
removeForcedDecision: vi.fn().mockReturnValue(true),
removeAllForcedDecisions: vi.fn().mockReturnValue(true),
trackEvent: vi.fn(),
getOptimizely: vi.fn(),
setQualifiedSegments: vi.fn(),
getQualifiedSegments: vi.fn().mockReturnValue([]),
qualifiedSegments: null,
...overrides,
} as unknown as OptimizelyUserContext;
}

/**
* Creates a mock Optimizely Client.
* @param hasConfig - If true, getOptimizelyConfig returns a config object; otherwise null.
*/
export function createMockClient(hasConfig = false): Client {
return {
getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null),
createUserContext: vi.fn(),
onReady: vi.fn().mockResolvedValue({ success: true }),
notificationCenter: {},
} as unknown as Client;
}

/**
* Creates a mock client with notification center support and wraps it in OptimizelyProvider.
* Used for integration-style tests that need the full Provider lifecycle.
*/
export function createProviderWrapper(mockUserContext: OptimizelyUserContext) {
let configUpdateCallback: (() => void) | undefined;

const client = {
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
createUserContext: vi.fn().mockReturnValue(mockUserContext),
onReady: vi.fn().mockResolvedValue(undefined),
isOdpIntegrated: vi.fn().mockReturnValue(false),
notificationCenter: {
addNotificationListener: vi.fn().mockImplementation((type: string, cb: () => void) => {
if (type === 'OPTIMIZELY_CONFIG_UPDATE') {
configUpdateCallback = cb;
}
return 1;
}),
removeNotificationListener: vi.fn(),
},
} as unknown as Client;

(client as unknown as Record<symbol, unknown>)[REACT_CLIENT_META] = {
hasOdpManager: false,
hasVuidManager: false,
};

function Wrapper({ children }: { children: React.ReactNode }) {
return (
<OptimizelyProvider client={client} user={{ id: 'user-1' }}>
{children}
</OptimizelyProvider>
);
}

return {
wrapper: Wrapper,
client,
fireConfigUpdate: () => configUpdateCallback?.(),
};
}

/**
* Creates a lightweight wrapper that provides OptimizelyContext directly
* (bypassing Provider lifecycle). Used for unit tests.
*/
export function createWrapper(store: ProviderStateStore, client: Client) {
const contextValue: OptimizelyContextValue = { store, client };

return function Wrapper({ children }: { children: React.ReactNode }) {
return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>;
};
}
123 changes: 31 additions & 92 deletions src/hooks/useDecide.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,66 +15,18 @@
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';
import React from 'react';
import { act } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { OptimizelyContext, ProviderStateStore } from '../provider/index';
import { ProviderStateStore } from '../provider/index';
import { useDecide } from './useDecide';
import type {
OptimizelyUserContext,
OptimizelyDecision,
Client,
OptimizelyDecideOption,
} from '@optimizely/optimizely-sdk';
import type { OptimizelyContextValue } from '../provider/index';

const MOCK_DECISION: OptimizelyDecision = {
variationKey: 'variation_1',
enabled: true,
variables: { color: 'red' },
ruleKey: 'rule_1',
flagKey: 'flag_1',
userContext: {} as OptimizelyUserContext,
reasons: [],
};

function createMockUserContext(overrides?: Partial<Record<'decide', unknown>>): OptimizelyUserContext {
return {
getUserId: vi.fn().mockReturnValue('test-user'),
getAttributes: vi.fn().mockReturnValue({}),
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
decide: vi.fn().mockReturnValue(MOCK_DECISION),
decideAll: vi.fn(),
decideForKeys: vi.fn(),
setForcedDecision: vi.fn().mockReturnValue(true),
getForcedDecision: vi.fn(),
removeForcedDecision: vi.fn().mockReturnValue(true),
removeAllForcedDecisions: vi.fn().mockReturnValue(true),
trackEvent: vi.fn(),
getOptimizely: vi.fn(),
setQualifiedSegments: vi.fn(),
getQualifiedSegments: vi.fn().mockReturnValue([]),
qualifiedSegments: null,
...overrides,
} as unknown as OptimizelyUserContext;
}

function createMockClient(hasConfig = false): Client {
return {
getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null),
createUserContext: vi.fn(),
onReady: vi.fn().mockResolvedValue({ success: true }),
notificationCenter: {},
} as unknown as Client;
}

function createWrapper(store: ProviderStateStore, client: Client) {
const contextValue: OptimizelyContextValue = { store, client };

return function Wrapper({ children }: { children: React.ReactNode }) {
return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>;
};
}
import {
MOCK_DECISION,
createMockUserContext,
createMockClient,
createProviderWrapper,
createWrapper,
} from './testUtils';
import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk';

describe('useDecide', () => {
let store: ProviderStateStore;
Expand Down Expand Up @@ -177,25 +129,6 @@ describe('useDecide', () => {
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should re-evaluate when setClientReady fire', async () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
// Client has no config yet
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });

expect(result.current.isLoading).toBe(true);

// Simulate config becoming available when onReady resolves
(mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '1' });
await act(async () => {
store.setClientReady(true);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should return error from store with isLoading: false', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
Expand Down Expand Up @@ -319,31 +252,37 @@ describe('useDecide', () => {
expect(result.current.decision).toBeNull();
});

it('should re-call decide() when setClientReady fires after sync decision was already served', async () => {
// Sync datafile scenario: config + userContext available before onReady
mockClient = createMockClient(true);
it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });

// Decision already served
expect(result.current.isLoading).toBe(false);
// Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.decision).toBe(MOCK_DECISION);
expect(mockUserContext.decide).toHaveBeenCalledTimes(1);

// onReady() resolves → setClientReady(true) fires → store state changes →
// useSyncExternalStore re-renders → useMemo recomputes → decide() called again.
// This is a redundant call since config + userContext haven't changed,
// but it's a one-time cost per flag per page load.
const callCountBeforeUpdate = (mockUserContext.decide as ReturnType<typeof vi.fn>).mock.calls.length;

// Simulate a new datafile with a different decision
const updatedDecision: OptimizelyDecision = {
...MOCK_DECISION,
variationKey: 'variation_2',
variables: { color: 'blue' },
};
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockReturnValue(updatedDecision);

// Fire the config update notification (as the SDK would on datafile poll)
await act(async () => {
store.setClientReady(true);
fireConfigUpdate();
});

expect(mockUserContext.decide).toHaveBeenCalledTimes(2);
expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1);
expect(result.current.decision).toBe(updatedDecision);
expect(result.current.isLoading).toBe(false);
expect(result.current.decision).toBe(MOCK_DECISION);
});

describe('forced decision reactivity', () => {
Expand Down
Loading
Loading