Skip to content

Commit 82c5e43

Browse files
[FSSDK-12293] useDecide m1
1 parent 6c0b314 commit 82c5e43

11 files changed

Lines changed: 542 additions & 22 deletions

src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@
1616

1717
export { useOptimizelyClient } from './useOptimizelyClient';
1818
export { useOptimizelyUserContext } from './useOptimizelyUserContext';
19+
export { useDecide } from './useDecide';
20+
export type { UseDecideConfig, UseDecideResult } from './useDecide';

src/hooks/useDecide.spec.tsx

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
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, describe, it, expect, beforeEach } from 'vitest';
18+
import React, { useRef } from 'react';
19+
import { act } from '@testing-library/react';
20+
import { renderHook } from '@testing-library/react';
21+
import { OptimizelyContext, ProviderStateStore } from '../provider/index';
22+
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(),
50+
getForcedDecision: vi.fn(),
51+
removeForcedDecision: vi.fn(),
52+
removeAllForcedDecisions: vi.fn(),
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+
}
78+
79+
function useRenderCount() {
80+
const renderCount = useRef(0);
81+
return ++renderCount.current;
82+
}
83+
84+
describe('useDecide', () => {
85+
let store: ProviderStateStore;
86+
let mockClient: Client;
87+
88+
beforeEach(() => {
89+
vi.clearAllMocks();
90+
store = new ProviderStateStore();
91+
mockClient = createMockClient();
92+
});
93+
94+
it('should throw when used outside of OptimizelyProvider', () => {
95+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
96+
97+
expect(() => {
98+
renderHook(() => useDecide('flag_1'));
99+
}).toThrow('Optimizely hooks must be used within an <OptimizelyProvider>');
100+
101+
consoleSpy.mockRestore();
102+
});
103+
104+
it('should return isLoading: true when no config and no user context', () => {
105+
const wrapper = createWrapper(store, mockClient);
106+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
107+
108+
expect(result.current.isLoading).toBe(true);
109+
expect(result.current.error).toBeNull();
110+
expect(result.current.decision.enabled).toBe(false);
111+
expect(result.current.decision.variationKey).toBeNull();
112+
expect(result.current.decision.flagKey).toBe('flag_1');
113+
});
114+
115+
it('should return isLoading: true when config is available but no user context', () => {
116+
mockClient = createMockClient(true);
117+
const wrapper = createWrapper(store, mockClient);
118+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
119+
120+
expect(result.current.isLoading).toBe(true);
121+
expect(result.current.error).toBeNull();
122+
});
123+
124+
it('should return isLoading: true when user context is set but no config', () => {
125+
const mockUserContext = createMockUserContext();
126+
store.setUserContext(mockUserContext);
127+
128+
const wrapper = createWrapper(store, mockClient);
129+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
130+
131+
expect(result.current.isLoading).toBe(true);
132+
expect(result.current.error).toBeNull();
133+
});
134+
135+
it('should return default decision while loading', () => {
136+
const wrapper = createWrapper(store, mockClient);
137+
const { result } = renderHook(() => useDecide('my_flag'), { wrapper });
138+
139+
const { decision } = result.current;
140+
expect(decision.enabled).toBe(false);
141+
expect(decision.variationKey).toBeNull();
142+
expect(decision.ruleKey).toBeNull();
143+
expect(decision.variables).toEqual({});
144+
expect(decision.flagKey).toBe('my_flag');
145+
expect(decision.reasons).toContain('Optimizely SDK not configured properly yet.');
146+
});
147+
148+
it('should return actual decision when config and user context are available', () => {
149+
mockClient = createMockClient(true);
150+
const mockUserContext = createMockUserContext();
151+
store.setUserContext(mockUserContext);
152+
153+
const wrapper = createWrapper(store, mockClient);
154+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
155+
156+
expect(result.current.isLoading).toBe(false);
157+
expect(result.current.error).toBeNull();
158+
expect(result.current.decision).toBe(MOCK_DECISION);
159+
expect(mockUserContext.decide).toHaveBeenCalledWith('flag_1', undefined);
160+
});
161+
162+
it('should pass decideOptions to userContext.decide()', () => {
163+
mockClient = createMockClient(true);
164+
const mockUserContext = createMockUserContext();
165+
store.setUserContext(mockUserContext);
166+
167+
const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[];
168+
169+
const wrapper = createWrapper(store, mockClient);
170+
renderHook(() => useDecide('flag_1', { decideOptions }), { wrapper });
171+
172+
expect(mockUserContext.decide).toHaveBeenCalledWith('flag_1', decideOptions);
173+
});
174+
175+
it('should re-evaluate when store state changes (user context set after mount)', () => {
176+
mockClient = createMockClient(true);
177+
const wrapper = createWrapper(store, mockClient);
178+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
179+
180+
expect(result.current.isLoading).toBe(true);
181+
182+
const mockUserContext = createMockUserContext();
183+
act(() => {
184+
store.setUserContext(mockUserContext);
185+
});
186+
187+
expect(result.current.isLoading).toBe(false);
188+
expect(result.current.decision).toBe(MOCK_DECISION);
189+
});
190+
191+
it('should re-evaluate when setClientReady fire', () => {
192+
const mockUserContext = createMockUserContext();
193+
store.setUserContext(mockUserContext);
194+
// Client has no config yet
195+
const wrapper = createWrapper(store, mockClient);
196+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
197+
198+
expect(result.current.isLoading).toBe(true);
199+
200+
// Simulate config becoming available when onReady resolves
201+
(mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '1' });
202+
act(() => {
203+
store.setClientReady(true);
204+
});
205+
206+
expect(result.current.isLoading).toBe(false);
207+
expect(result.current.decision).toBe(MOCK_DECISION);
208+
});
209+
210+
it('should return error from store with isLoading: false', () => {
211+
const wrapper = createWrapper(store, mockClient);
212+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
213+
214+
expect(result.current.isLoading).toBe(true);
215+
216+
const testError = new Error('SDK initialization failed');
217+
act(() => {
218+
store.setError(testError);
219+
});
220+
221+
expect(result.current.isLoading).toBe(false);
222+
expect(result.current.error).toBe(testError);
223+
expect(result.current.decision.enabled).toBe(false);
224+
expect(result.current.decision.variationKey).toBeNull();
225+
});
226+
227+
it('should re-evaluate when flagKey changes', () => {
228+
mockClient = createMockClient(true);
229+
const mockUserContext = createMockUserContext();
230+
231+
const decisionForFlag2: OptimizelyDecision = {
232+
...MOCK_DECISION,
233+
flagKey: 'flag_2',
234+
variationKey: 'variation_2',
235+
};
236+
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
237+
return key === 'flag_2' ? decisionForFlag2 : MOCK_DECISION;
238+
});
239+
240+
store.setUserContext(mockUserContext);
241+
242+
const wrapper = createWrapper(store, mockClient);
243+
const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), {
244+
wrapper,
245+
initialProps: { flagKey: 'flag_1' },
246+
});
247+
248+
expect(result.current.decision).toBe(MOCK_DECISION);
249+
250+
rerender({ flagKey: 'flag_2' });
251+
252+
expect(result.current.decision).toBe(decisionForFlag2);
253+
expect(mockUserContext.decide).toHaveBeenCalledWith('flag_2', undefined);
254+
});
255+
256+
it('should return stable reference when nothing changes', () => {
257+
mockClient = createMockClient(true);
258+
const mockUserContext = createMockUserContext();
259+
store.setUserContext(mockUserContext);
260+
261+
const wrapper = createWrapper(store, mockClient);
262+
const { result, rerender } = renderHook(() => useDecide('flag_1'), { wrapper });
263+
264+
const firstResult = result.current;
265+
rerender();
266+
267+
expect(result.current).toBe(firstResult);
268+
});
269+
270+
it('should handle decideOptions referential stability via useStableArray', () => {
271+
mockClient = createMockClient(true);
272+
const mockUserContext = createMockUserContext();
273+
store.setUserContext(mockUserContext);
274+
275+
const wrapper = createWrapper(store, mockClient);
276+
277+
// Pass inline array (new reference each render) with same elements
278+
const { result, rerender } = renderHook(
279+
() => useDecide('flag_1', { decideOptions: ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[] }),
280+
{ wrapper }
281+
);
282+
283+
const firstResult = result.current;
284+
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockClear();
285+
286+
rerender();
287+
288+
// Should NOT re-call decide() since the array elements are the same
289+
expect(mockUserContext.decide).not.toHaveBeenCalled();
290+
expect(result.current).toBe(firstResult);
291+
});
292+
293+
it('should unsubscribe from store on unmount', () => {
294+
const unsubscribeSpy = vi.fn();
295+
const subscribeSpy = vi.spyOn(store, 'subscribe').mockReturnValue(unsubscribeSpy);
296+
297+
const wrapper = createWrapper(store, mockClient);
298+
const { unmount } = renderHook(() => useDecide('flag_1'), { wrapper });
299+
300+
expect(subscribeSpy).toHaveBeenCalledTimes(1);
301+
expect(unsubscribeSpy).not.toHaveBeenCalled();
302+
303+
unmount();
304+
305+
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
306+
});
307+
308+
it('should not call decide() while loading', () => {
309+
const mockUserContext = createMockUserContext();
310+
// Config not available, user context set
311+
store.setUserContext(mockUserContext);
312+
313+
const wrapper = createWrapper(store, mockClient);
314+
renderHook(() => useDecide('flag_1'), { wrapper });
315+
316+
// decide should not be called because config is not available
317+
expect(mockUserContext.decide).not.toHaveBeenCalled();
318+
});
319+
320+
it('should update default decision flagKey when flagKey changes', () => {
321+
const wrapper = createWrapper(store, mockClient);
322+
const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), {
323+
wrapper,
324+
initialProps: { flagKey: 'flag_a' },
325+
});
326+
327+
expect(result.current.decision.flagKey).toBe('flag_a');
328+
329+
rerender({ flagKey: 'flag_b' });
330+
331+
expect(result.current.decision.flagKey).toBe('flag_b');
332+
});
333+
334+
it('should re-call decide() when setClientReady fires after sync decision was already served', () => {
335+
// Sync datafile scenario: config + userContext available before onReady
336+
mockClient = createMockClient(true);
337+
const mockUserContext = createMockUserContext();
338+
store.setUserContext(mockUserContext);
339+
340+
const wrapper = createWrapper(store, mockClient);
341+
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
342+
343+
// Decision already served
344+
expect(result.current.isLoading).toBe(false);
345+
expect(result.current.decision).toBe(MOCK_DECISION);
346+
expect(mockUserContext.decide).toHaveBeenCalledTimes(1);
347+
348+
// onReady() resolves → setClientReady(true) fires → store state changes →
349+
// useSyncExternalStore re-renders → useMemo recomputes → decide() called again.
350+
// This is a redundant call since config + userContext haven't changed,
351+
// but it's a one-time cost per flag per page load.
352+
act(() => {
353+
store.setClientReady(true);
354+
});
355+
356+
expect(mockUserContext.decide).toHaveBeenCalledTimes(2);
357+
expect(result.current.isLoading).toBe(false);
358+
expect(result.current.decision).toBe(MOCK_DECISION);
359+
});
360+
});

0 commit comments

Comments
 (0)