Skip to content

Commit 899e2d8

Browse files
[FSSDK-12294] sync hooks impl (#322)
1 parent 0659716 commit 899e2d8

18 files changed

Lines changed: 1327 additions & 213 deletions

src/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ export { useOptimizelyUserContext } from './useOptimizelyUserContext';
1919
export type { UseOptimizelyUserContextResult } from './useOptimizelyUserContext';
2020
export { useDecide } from './useDecide';
2121
export type { UseDecideConfig, UseDecideResult } from './useDecide';
22+
export { useDecideForKeys } from './useDecideForKeys';
23+
export type { UseDecideMultiResult } from './useDecideForKeys';
24+
export { useDecideAll } from './useDecideAll';

src/hooks/testUtils.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { vi } from 'vitest';
18+
import React from 'react';
19+
import { OptimizelyContext, ProviderStateStore, OptimizelyProvider } from '../provider/index';
20+
import { REACT_CLIENT_META } from '../client/index';
21+
import type { OptimizelyUserContext, OptimizelyDecision, Client } from '@optimizely/optimizely-sdk';
22+
import type { OptimizelyContextValue } from '../provider/index';
23+
24+
export const MOCK_DECISION: OptimizelyDecision = {
25+
variationKey: 'variation_1',
26+
enabled: true,
27+
variables: { color: 'red' },
28+
ruleKey: 'rule_1',
29+
flagKey: 'flag_1',
30+
userContext: {} as OptimizelyUserContext,
31+
reasons: [],
32+
};
33+
34+
export const MOCK_DECISIONS: Record<string, OptimizelyDecision> = {
35+
flag_1: MOCK_DECISION,
36+
flag_2: {
37+
variationKey: 'variation_2',
38+
enabled: false,
39+
variables: { size: 'large' },
40+
ruleKey: 'rule_2',
41+
flagKey: 'flag_2',
42+
userContext: {} as OptimizelyUserContext,
43+
reasons: [],
44+
},
45+
};
46+
47+
/**
48+
* Creates a mock OptimizelyUserContext with all methods stubbed.
49+
* Override specific methods via the overrides parameter.
50+
*/
51+
export function createMockUserContext(
52+
overrides?: Partial<Record<string, unknown>>,
53+
): OptimizelyUserContext {
54+
return {
55+
getUserId: vi.fn().mockReturnValue('test-user'),
56+
getAttributes: vi.fn().mockReturnValue({}),
57+
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
58+
decide: vi.fn().mockReturnValue(MOCK_DECISION),
59+
decideAll: vi.fn().mockReturnValue(MOCK_DECISIONS),
60+
decideForKeys: vi.fn().mockImplementation((keys: string[]) => {
61+
const result: Record<string, OptimizelyDecision> = {};
62+
for (const key of keys) {
63+
if (MOCK_DECISIONS[key]) {
64+
result[key] = MOCK_DECISIONS[key];
65+
}
66+
}
67+
return result;
68+
}),
69+
setForcedDecision: vi.fn().mockReturnValue(true),
70+
getForcedDecision: vi.fn(),
71+
removeForcedDecision: vi.fn().mockReturnValue(true),
72+
removeAllForcedDecisions: vi.fn().mockReturnValue(true),
73+
trackEvent: vi.fn(),
74+
getOptimizely: vi.fn(),
75+
setQualifiedSegments: vi.fn(),
76+
getQualifiedSegments: vi.fn().mockReturnValue([]),
77+
qualifiedSegments: null,
78+
...overrides,
79+
} as unknown as OptimizelyUserContext;
80+
}
81+
82+
/**
83+
* Creates a mock Optimizely Client.
84+
* @param hasConfig - If true, getOptimizelyConfig returns a config object; otherwise null.
85+
*/
86+
export function createMockClient(hasConfig = false): Client {
87+
return {
88+
getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null),
89+
createUserContext: vi.fn(),
90+
onReady: vi.fn().mockResolvedValue({ success: true }),
91+
notificationCenter: {},
92+
} as unknown as Client;
93+
}
94+
95+
/**
96+
* Creates a mock client with notification center support and wraps it in OptimizelyProvider.
97+
* Used for integration-style tests that need the full Provider lifecycle.
98+
*/
99+
export function createProviderWrapper(mockUserContext: OptimizelyUserContext) {
100+
let configUpdateCallback: (() => void) | undefined;
101+
102+
const client = {
103+
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
104+
createUserContext: vi.fn().mockReturnValue(mockUserContext),
105+
onReady: vi.fn().mockResolvedValue(undefined),
106+
isOdpIntegrated: vi.fn().mockReturnValue(false),
107+
notificationCenter: {
108+
addNotificationListener: vi.fn().mockImplementation((type: string, cb: () => void) => {
109+
if (type === 'OPTIMIZELY_CONFIG_UPDATE') {
110+
configUpdateCallback = cb;
111+
}
112+
return 1;
113+
}),
114+
removeNotificationListener: vi.fn(),
115+
},
116+
} as unknown as Client;
117+
118+
(client as unknown as Record<symbol, unknown>)[REACT_CLIENT_META] = {
119+
hasOdpManager: false,
120+
hasVuidManager: false,
121+
};
122+
123+
function Wrapper({ children }: { children: React.ReactNode }) {
124+
return (
125+
<OptimizelyProvider client={client} user={{ id: 'user-1' }}>
126+
{children}
127+
</OptimizelyProvider>
128+
);
129+
}
130+
131+
return {
132+
wrapper: Wrapper,
133+
client,
134+
fireConfigUpdate: () => configUpdateCallback?.(),
135+
};
136+
}
137+
138+
/**
139+
* Creates a lightweight wrapper that provides OptimizelyContext directly
140+
* (bypassing Provider lifecycle). Used for unit tests.
141+
*/
142+
export function createWrapper(store: ProviderStateStore, client: Client) {
143+
const contextValue: OptimizelyContextValue = { store, client };
144+
145+
return function Wrapper({ children }: { children: React.ReactNode }) {
146+
return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>;
147+
};
148+
}

src/hooks/useDecide.spec.tsx

Lines changed: 31 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -15,66 +15,18 @@
1515
*/
1616

1717
import { vi, describe, it, expect, beforeEach } from 'vitest';
18-
import React from 'react';
19-
import { act } from '@testing-library/react';
18+
import { act, waitFor } from '@testing-library/react';
2019
import { renderHook } from '@testing-library/react';
21-
import { OptimizelyContext, ProviderStateStore } from '../provider/index';
20+
import { ProviderStateStore } from '../provider/index';
2221
import { useDecide } from './useDecide';
23-
import type {
24-
OptimizelyUserContext,
25-
OptimizelyDecision,
26-
Client,
27-
OptimizelyDecideOption,
28-
} from '@optimizely/optimizely-sdk';
29-
import type { OptimizelyContextValue } from '../provider/index';
30-
31-
const MOCK_DECISION: OptimizelyDecision = {
32-
variationKey: 'variation_1',
33-
enabled: true,
34-
variables: { color: 'red' },
35-
ruleKey: 'rule_1',
36-
flagKey: 'flag_1',
37-
userContext: {} as OptimizelyUserContext,
38-
reasons: [],
39-
};
40-
41-
function createMockUserContext(overrides?: Partial<Record<'decide', unknown>>): OptimizelyUserContext {
42-
return {
43-
getUserId: vi.fn().mockReturnValue('test-user'),
44-
getAttributes: vi.fn().mockReturnValue({}),
45-
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
46-
decide: vi.fn().mockReturnValue(MOCK_DECISION),
47-
decideAll: vi.fn(),
48-
decideForKeys: vi.fn(),
49-
setForcedDecision: vi.fn().mockReturnValue(true),
50-
getForcedDecision: vi.fn(),
51-
removeForcedDecision: vi.fn().mockReturnValue(true),
52-
removeAllForcedDecisions: vi.fn().mockReturnValue(true),
53-
trackEvent: vi.fn(),
54-
getOptimizely: vi.fn(),
55-
setQualifiedSegments: vi.fn(),
56-
getQualifiedSegments: vi.fn().mockReturnValue([]),
57-
qualifiedSegments: null,
58-
...overrides,
59-
} as unknown as OptimizelyUserContext;
60-
}
61-
62-
function createMockClient(hasConfig = false): Client {
63-
return {
64-
getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null),
65-
createUserContext: vi.fn(),
66-
onReady: vi.fn().mockResolvedValue({ success: true }),
67-
notificationCenter: {},
68-
} as unknown as Client;
69-
}
70-
71-
function createWrapper(store: ProviderStateStore, client: Client) {
72-
const contextValue: OptimizelyContextValue = { store, client };
73-
74-
return function Wrapper({ children }: { children: React.ReactNode }) {
75-
return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>;
76-
};
77-
}
22+
import {
23+
MOCK_DECISION,
24+
createMockUserContext,
25+
createMockClient,
26+
createProviderWrapper,
27+
createWrapper,
28+
} from './testUtils';
29+
import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk';
7830

7931
describe('useDecide', () => {
8032
let store: ProviderStateStore;
@@ -177,25 +129,6 @@ describe('useDecide', () => {
177129
expect(result.current.decision).toBe(MOCK_DECISION);
178130
});
179131

180-
it('should re-evaluate when setClientReady fire', async () => {
181-
const mockUserContext = createMockUserContext();
182-
store.setUserContext(mockUserContext);
183-
// Client has no config yet
184-
const wrapper = createWrapper(store, mockClient);
185-
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
186-
187-
expect(result.current.isLoading).toBe(true);
188-
189-
// Simulate config becoming available when onReady resolves
190-
(mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '1' });
191-
await act(async () => {
192-
store.setClientReady(true);
193-
});
194-
195-
expect(result.current.isLoading).toBe(false);
196-
expect(result.current.decision).toBe(MOCK_DECISION);
197-
});
198-
199132
it('should return error from store with isLoading: false', async () => {
200133
const wrapper = createWrapper(store, mockClient);
201134
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
@@ -319,31 +252,37 @@ describe('useDecide', () => {
319252
expect(result.current.decision).toBeNull();
320253
});
321254

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

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

331-
// Decision already served
332-
expect(result.current.isLoading).toBe(false);
261+
// Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete
262+
await waitFor(() => {
263+
expect(result.current.isLoading).toBe(false);
264+
});
265+
333266
expect(result.current.decision).toBe(MOCK_DECISION);
334-
expect(mockUserContext.decide).toHaveBeenCalledTimes(1);
335267

336-
// onReady() resolves → setClientReady(true) fires → store state changes →
337-
// useSyncExternalStore re-renders → useMemo recomputes → decide() called again.
338-
// This is a redundant call since config + userContext haven't changed,
339-
// but it's a one-time cost per flag per page load.
268+
const callCountBeforeUpdate = (mockUserContext.decide as ReturnType<typeof vi.fn>).mock.calls.length;
269+
270+
// Simulate a new datafile with a different decision
271+
const updatedDecision: OptimizelyDecision = {
272+
...MOCK_DECISION,
273+
variationKey: 'variation_2',
274+
variables: { color: 'blue' },
275+
};
276+
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockReturnValue(updatedDecision);
277+
278+
// Fire the config update notification (as the SDK would on datafile poll)
340279
await act(async () => {
341-
store.setClientReady(true);
280+
fireConfigUpdate();
342281
});
343282

344-
expect(mockUserContext.decide).toHaveBeenCalledTimes(2);
283+
expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1);
284+
expect(result.current.decision).toBe(updatedDecision);
345285
expect(result.current.isLoading).toBe(false);
346-
expect(result.current.decision).toBe(MOCK_DECISION);
347286
});
348287

349288
describe('forced decision reactivity', () => {

src/hooks/useDecide.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useCallback, useEffect, useMemo, useState } from 'react';
18-
import { useSyncExternalStore } from 'use-sync-external-store/shim';
17+
import { useEffect, useMemo, useState } from 'react';
1918
import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/optimizely-sdk';
2019

2120
import { useOptimizelyContext } from './useOptimizelyContext';
21+
import { useProviderState } from './useProviderState';
2222
import { useStableArray } from './useStableArray';
2323

2424
export interface UseDecideConfig {
@@ -43,12 +43,7 @@ export type UseDecideResult =
4343
export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideResult {
4444
const { store, client } = useOptimizelyContext();
4545
const decideOptions = useStableArray(config?.decideOptions);
46-
// --- General state subscription ---
47-
// store.getState() returns a new object on every state change,
48-
// so Object.is comparison works naturally.
49-
const subscribeState = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]);
50-
const getStateSnapshot = useCallback(() => store.getState(), [store]);
51-
const state = useSyncExternalStore(subscribeState, getStateSnapshot, getStateSnapshot);
46+
const state = useProviderState(store);
5247

5348
// --- Forced decision subscription ---
5449
// Forced decisions don't change store state, so we use a version counter

0 commit comments

Comments
 (0)