Skip to content

Commit bfe2941

Browse files
[FSSDK-12296] decideForKeysAsync, decideAllAsync impl.
1 parent c967727 commit bfe2941

7 files changed

Lines changed: 653 additions & 53 deletions

File tree

src/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ export type { UseDecideMultiResult } from './useDecideForKeys';
2424
export { useDecideAll } from './useDecideAll';
2525
export { useDecideAsync } from './useDecideAsync';
2626
export type { UseDecideAsyncResult } from './useDecideAsync';
27+
export { useDecideForKeysAsync } from './useDecideForKeysAsync';
28+
export type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync';
29+
export { useDecideAllAsync } from './useDecideAllAsync';

src/hooks/useDecideAllAsync.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 { useEffect, useState } from 'react';
18+
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';
19+
20+
import { useOptimizelyContext } from './useOptimizelyContext';
21+
import { useProviderState } from './useProviderState';
22+
import { useStableArray } from './useStableArray';
23+
import type { UseDecideConfig } from './useDecide';
24+
import type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync';
25+
26+
/**
27+
* Returns feature flag decisions for all flags using the async
28+
* `decideAllAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support.
29+
*
30+
* Client-side only — `decideAllAsync` returns a Promise which cannot resolve
31+
* during server render.
32+
*
33+
* @param config - Optional configuration (decideOptions)
34+
*/
35+
export function useDecideAllAsync(config?: UseDecideConfig): UseDecideMultiAsyncResult {
36+
const { store, client } = useOptimizelyContext();
37+
const decideOptions = useStableArray(config?.decideOptions);
38+
const state = useProviderState(store);
39+
40+
// --- Forced decision subscription — any flag key ---
41+
const [fdVersion, setFdVersion] = useState(0);
42+
useEffect(() => {
43+
return store.subscribeAllForcedDecisions(() => setFdVersion((v) => v + 1));
44+
}, [store]);
45+
46+
// --- Async decision state ---
47+
const [asyncState, setAsyncState] = useState<{
48+
decisions: Record<string, OptimizelyDecision> | Record<string, never>;
49+
error: Error | null;
50+
isLoading: boolean;
51+
}>({ decisions: {} as Record<string, never>, error: null, isLoading: true });
52+
53+
// --- Async decision effect ---
54+
useEffect(() => {
55+
const { userContext, error } = state;
56+
const hasConfig = client.getOptimizelyConfig() !== null;
57+
58+
// Store-level error — no async call needed
59+
if (error) {
60+
setAsyncState({ decisions: {} as Record<string, never>, error, isLoading: false });
61+
return;
62+
}
63+
64+
// Store not ready — stay in loading
65+
if (!hasConfig || userContext === null) {
66+
setAsyncState({ decisions: {} as Record<string, never>, error: null, isLoading: true });
67+
return;
68+
}
69+
70+
// Store is ready — fire async decision
71+
let cancelled = false;
72+
// Reset to loading before firing the async call.
73+
// If already in the initial loading state, returns `prev` as-is to
74+
// skip a redundant re-render on first mount.
75+
setAsyncState((prev) => {
76+
if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev;
77+
return { decisions: {} as Record<string, never>, error: null, isLoading: true };
78+
});
79+
80+
userContext.decideAllAsync(decideOptions).then(
81+
(decisions) => {
82+
if (!cancelled) {
83+
setAsyncState({ decisions, error: null, isLoading: false });
84+
}
85+
},
86+
(err) => {
87+
if (!cancelled) {
88+
setAsyncState({
89+
decisions: {} as Record<string, never>,
90+
error: err instanceof Error ? err : new Error(String(err)),
91+
isLoading: false,
92+
});
93+
}
94+
}
95+
);
96+
97+
return () => {
98+
cancelled = true;
99+
};
100+
}, [state, fdVersion, client, decideOptions]);
101+
102+
return asyncState as UseDecideMultiAsyncResult;
103+
}

src/hooks/useDecideAsync.spec.tsx

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -99,37 +99,40 @@ describe('useDecideAsync', () => {
9999
expect(result.current.error).toBeNull();
100100
});
101101

102-
it('should return decision when async call resolves', async () => {
102+
it('should not trigger a redundant re-render when mounting with store already ready', () => {
103103
mockClient = createMockClient(true);
104104
const mockUserContext = createMockUserContext();
105+
(mockUserContext.decideAsync as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
105106
store.setUserContext(mockUserContext);
106107

108+
let renderCount = 0;
107109
const wrapper = createWrapper(store, mockClient);
108-
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });
109-
110-
await waitFor(() => {
111-
expect(result.current.isLoading).toBe(false);
112-
});
113-
114-
expect(result.current.decision).toBe(MOCK_DECISION);
115-
expect(result.current.error).toBeNull();
110+
renderHook(
111+
() => {
112+
renderCount++;
113+
return useDecideAsync('flag_1');
114+
},
115+
{ wrapper }
116+
);
117+
118+
// Should render once (initial), not twice (initial + redundant setState)
119+
expect(renderCount).toBe(1);
116120
});
117121

118-
it('should pass decideOptions to decideAsync()', async () => {
122+
it('should return decision when async call resolves', async () => {
119123
mockClient = createMockClient(true);
120124
const mockUserContext = createMockUserContext();
121125
store.setUserContext(mockUserContext);
122126

123-
const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[];
124-
125127
const wrapper = createWrapper(store, mockClient);
126-
const { result } = renderHook(() => useDecideAsync('flag_1', { decideOptions }), { wrapper });
128+
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });
127129

128130
await waitFor(() => {
129131
expect(result.current.isLoading).toBe(false);
130132
});
131133

132-
expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', decideOptions);
134+
expect(result.current.decision).toBe(MOCK_DECISION);
135+
expect(result.current.error).toBeNull();
133136
});
134137

135138
it('should return error when decideAsync() rejects', async () => {
@@ -280,34 +283,6 @@ describe('useDecideAsync', () => {
280283
expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', newOptions);
281284
});
282285

283-
it('should re-fire async call when user context changes (store state)', async () => {
284-
mockClient = createMockClient(true);
285-
const mockUserContext1 = createMockUserContext();
286-
store.setUserContext(mockUserContext1);
287-
288-
const wrapper = createWrapper(store, mockClient);
289-
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });
290-
291-
await waitFor(() => {
292-
expect(result.current.isLoading).toBe(false);
293-
});
294-
295-
expect(result.current.decision).toBe(MOCK_DECISION);
296-
expect(mockUserContext1.decideAsync).toHaveBeenCalledTimes(1);
297-
298-
// Now change to a different user context
299-
const updatedDecision: OptimizelyDecision = { ...MOCK_DECISION, variationKey: 'variation_2' };
300-
const mockUserContext2 = createMockUserContext();
301-
(mockUserContext2.decideAsync as ReturnType<typeof vi.fn>).mockResolvedValue(updatedDecision);
302-
303-
await act(async () => {
304-
store.setUserContext(mockUserContext2);
305-
});
306-
307-
expect(result.current.decision).toBe(updatedDecision);
308-
expect(mockUserContext2.decideAsync).toHaveBeenCalledTimes(1);
309-
});
310-
311286
it('should re-fire async call on config update', async () => {
312287
const mockUserContext = createMockUserContext();
313288
const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext);
@@ -435,15 +410,4 @@ describe('useDecideAsync', () => {
435410
expect(unsubscribeFdSpy).toHaveBeenCalledTimes(1);
436411
});
437412
});
438-
439-
it('should not call decideAsync() while loading', () => {
440-
const mockUserContext = createMockUserContext();
441-
// Config not available, user context set
442-
store.setUserContext(mockUserContext);
443-
444-
const wrapper = createWrapper(store, mockClient);
445-
renderHook(() => useDecideAsync('flag_1'), { wrapper });
446-
447-
expect(mockUserContext.decideAsync).not.toHaveBeenCalled();
448-
});
449413
});

src/hooks/useDecideAsync.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ export function useDecideAsync(flagKey: string, config?: UseDecideConfig): UseDe
7676

7777
// Store is ready — fire async decision
7878
let cancelled = false;
79+
// Reset to loading before firing the async call.
80+
// If already in the initial loading state, returns `prev` as-is to
81+
// skip a redundant re-render on first mount.
7982
setAsyncState((prev) => {
8083
if (prev.isLoading && prev.error === null && prev.decision === null) return prev;
8184
return { decision: null, error: null, isLoading: true };

0 commit comments

Comments
 (0)