Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"test:coverage": "vitest run --coverage",
"test:legacy": "jest --silent",
"prepublishOnly": "npm run test && npm run build",
"prepare": "npm run build && husky"
"prepare": "husky"
Comment thread
junaed-optimizely marked this conversation as resolved.
Outdated
},
"publishConfig": {
"access": "public"
Expand Down
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