From c9677274ec5aa24e2ae1e8fe742c4f508f43dc52 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:15:39 +0600 Subject: [PATCH 1/4] [FSSDK-12296] decideAsync impl. --- src/hooks/index.ts | 2 + src/hooks/testUtils.tsx | 15 +- src/hooks/useDecideAsync.spec.tsx | 449 ++++++++++++++++++++++++++++++ src/hooks/useDecideAsync.ts | 107 +++++++ src/index.ts | 1 + 5 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useDecideAsync.spec.tsx create mode 100644 src/hooks/useDecideAsync.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 988ca53..7edfec0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,5 @@ export type { UseDecideConfig, UseDecideResult } from './useDecide'; export { useDecideForKeys } from './useDecideForKeys'; export type { UseDecideMultiResult } from './useDecideForKeys'; export { useDecideAll } from './useDecideAll'; +export { useDecideAsync } from './useDecideAsync'; +export type { UseDecideAsyncResult } from './useDecideAsync'; diff --git a/src/hooks/testUtils.tsx b/src/hooks/testUtils.tsx index 36c3375..657f052 100644 --- a/src/hooks/testUtils.tsx +++ b/src/hooks/testUtils.tsx @@ -48,9 +48,7 @@ export const MOCK_DECISIONS: Record = { * Creates a mock OptimizelyUserContext with all methods stubbed. * Override specific methods via the overrides parameter. */ -export function createMockUserContext( - overrides?: Partial>, -): OptimizelyUserContext { +export function createMockUserContext(overrides?: Partial>): OptimizelyUserContext { return { getUserId: vi.fn().mockReturnValue('test-user'), getAttributes: vi.fn().mockReturnValue({}), @@ -66,6 +64,17 @@ export function createMockUserContext( } return result; }), + decideAsync: vi.fn().mockResolvedValue(MOCK_DECISION), + decideAllAsync: vi.fn().mockResolvedValue(MOCK_DECISIONS), + decideForKeysAsync: vi.fn().mockImplementation((keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + if (MOCK_DECISIONS[key]) { + result[key] = MOCK_DECISIONS[key]; + } + } + return Promise.resolve(result); + }), setForcedDecision: vi.fn().mockReturnValue(true), getForcedDecision: vi.fn(), removeForcedDecision: vi.fn().mockReturnValue(true), diff --git a/src/hooks/useDecideAsync.spec.tsx b/src/hooks/useDecideAsync.spec.tsx new file mode 100644 index 0000000..357c56e --- /dev/null +++ b/src/hooks/useDecideAsync.spec.tsx @@ -0,0 +1,449 @@ +/** + * 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, describe, it, expect, beforeEach } from 'vitest'; +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { ProviderStateStore } from '../provider/index'; +import { useDecideAsync } from './useDecideAsync'; +import { + MOCK_DECISION, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; + +describe('useDecideAsync', () => { + let store: ProviderStateStore; + let mockClient: Client; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + mockClient = createMockClient(); + }); + + // --- Store state tests --- + + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDecideAsync('flag_1')); + }).toThrow('Optimizely hooks must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return isLoading: true when no config and no user context', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.decision).toBeNull(); + }); + + it('should return isLoading: true when config is available but no user context', () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return error from store with isLoading: false', async () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const testError = new Error('SDK initialization failed'); + await act(async () => { + store.setError(testError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(testError); + expect(result.current.decision).toBeNull(); + }); + + it('should return isLoading: true while async call is in-flight', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + // Make decideAsync never resolve + (mockUserContext.decideAsync as ReturnType).mockReturnValue(new Promise(() => {})); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.decision).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('should return decision when async call resolves', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decision).toBe(MOCK_DECISION); + expect(result.current.error).toBeNull(); + }); + + it('should pass decideOptions to decideAsync()', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1', { decideOptions }), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', decideOptions); + }); + + it('should return error when decideAsync() rejects', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + const asyncError = new Error('CMAB request failed'); + (mockUserContext.decideAsync as ReturnType).mockRejectedValue(asyncError); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(asyncError); + expect(result.current.decision).toBeNull(); + }); + + it('should wrap non-Error rejection in Error object', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideAsync as ReturnType).mockRejectedValue('string error'); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error!.message).toBe('string error'); + expect(result.current.decision).toBeNull(); + }); + + // --- Race condition tests --- + + it('should discard stale result when flagKey changes before resolve', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + + let resolveFirst: (d: OptimizelyDecision) => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const decisionForFlag2: OptimizelyDecision = { + ...MOCK_DECISION, + flagKey: 'flag_2', + variationKey: 'variation_2', + }; + + (mockUserContext.decideAsync as ReturnType) + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce(decisionForFlag2); + + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ flagKey }) => useDecideAsync(flagKey), { + wrapper, + initialProps: { flagKey: 'flag_1' }, + }); + + // Change flagKey before first resolves + rerender({ flagKey: 'flag_2' }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decision).toBe(decisionForFlag2); + + // Now resolve the stale first promise — should be ignored + await act(async () => { + resolveFirst!(MOCK_DECISION); + }); + + // Should still show flag_2's decision + expect(result.current.decision).toBe(decisionForFlag2); + }); + + it('should discard stale result when store state changes before resolve', async () => { + mockClient = createMockClient(true); + const mockUserContext1 = createMockUserContext(); + + let resolveFirst: (d: OptimizelyDecision) => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + (mockUserContext1.decideAsync as ReturnType).mockReturnValue(firstPromise); + store.setUserContext(mockUserContext1); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + // New user context arrives — cancels first async call + const updatedDecision: OptimizelyDecision = { ...MOCK_DECISION, variationKey: 'updated' }; + const mockUserContext2 = createMockUserContext(); + (mockUserContext2.decideAsync as ReturnType).mockResolvedValue(updatedDecision); + + await act(async () => { + store.setUserContext(mockUserContext2); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decision).toBe(updatedDecision); + + // Resolve the stale first promise — should be ignored + await act(async () => { + resolveFirst!(MOCK_DECISION); + }); + + expect(result.current.decision).toBe(updatedDecision); + }); + + // --- Re-evaluation tests --- + + it('should re-fire async call when decideOptions change', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ options }) => useDecideAsync('flag_1', { decideOptions: options }), { + wrapper, + initialProps: { options: undefined as OptimizelyDecideOption[] | undefined }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const newOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + (mockUserContext.decideAsync as ReturnType).mockClear(); + + rerender({ options: newOptions }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', newOptions); + }); + + it('should re-fire async call when user context changes (store state)', async () => { + mockClient = createMockClient(true); + const mockUserContext1 = createMockUserContext(); + store.setUserContext(mockUserContext1); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decision).toBe(MOCK_DECISION); + expect(mockUserContext1.decideAsync).toHaveBeenCalledTimes(1); + + // Now change to a different user context + const updatedDecision: OptimizelyDecision = { ...MOCK_DECISION, variationKey: 'variation_2' }; + const mockUserContext2 = createMockUserContext(); + (mockUserContext2.decideAsync as ReturnType).mockResolvedValue(updatedDecision); + + await act(async () => { + store.setUserContext(mockUserContext2); + }); + + expect(result.current.decision).toBe(updatedDecision); + expect(mockUserContext2.decideAsync).toHaveBeenCalledTimes(1); + }); + + it('should re-fire async call on config update', async () => { + const mockUserContext = createMockUserContext(); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); + + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decision).toBe(MOCK_DECISION); + + const callCountBeforeUpdate = (mockUserContext.decideAsync as ReturnType).mock.calls.length; + + const updatedDecision: OptimizelyDecision = { + ...MOCK_DECISION, + variationKey: 'variation_2', + variables: { color: 'blue' }, + }; + (mockUserContext.decideAsync as ReturnType).mockResolvedValue(updatedDecision); + + await act(async () => { + fireConfigUpdate(); + }); + + expect(result.current.decision).toBe(updatedDecision); + expect(mockUserContext.decideAsync).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.isLoading).toBe(false); + }); + + // --- Forced decision tests --- + + describe('forced decision reactivity', () => { + it('should re-fire async call when setForcedDecision is called for the same flagKey', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledTimes(1); + + const forcedDecision: OptimizelyDecision = { + ...MOCK_DECISION, + variationKey: 'forced_variation', + }; + (mockUserContext.decideAsync as ReturnType).mockResolvedValue(forcedDecision); + + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledTimes(2); + expect(result.current.decision).toBe(forcedDecision); + }); + + it('should NOT re-fire for a different flagKey forced decision', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + (mockUserContext.decideAsync as ReturnType).mockClear(); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_2' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAsync).not.toHaveBeenCalled(); + }); + + it('should re-fire when removeAllForcedDecisions is called', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Set a forced decision to register the flagKey + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledTimes(2); + + await act(async () => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decideAsync).toHaveBeenCalledTimes(3); + }); + + it('should unsubscribe forced decision listener on unmount', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const unsubscribeFdSpy = vi.fn(); + const subscribeFdSpy = vi.spyOn(store, 'subscribeForcedDecision').mockReturnValue(unsubscribeFdSpy); + + const wrapper = createWrapper(store, mockClient); + const { unmount } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(subscribeFdSpy).toHaveBeenCalledTimes(1); + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_1', expect.any(Function)); + + unmount(); + + expect(unsubscribeFdSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should not call decideAsync() while loading', () => { + const mockUserContext = createMockUserContext(); + // Config not available, user context set + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAsync('flag_1'), { wrapper }); + + expect(mockUserContext.decideAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useDecideAsync.ts b/src/hooks/useDecideAsync.ts new file mode 100644 index 0000000..69cf6d9 --- /dev/null +++ b/src/hooks/useDecideAsync.ts @@ -0,0 +1,107 @@ +/** + * 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 { useEffect, useState } from 'react'; +import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useProviderState } from './useProviderState'; +import { useStableArray } from './useStableArray'; +import type { UseDecideConfig } from './useDecide'; + +export type UseDecideAsyncResult = + | { isLoading: true; error: null; decision: null } + | { isLoading: false; error: Error; decision: null } + | { isLoading: false; error: null; decision: OptimizelyDecision }; + +/** + * Returns a feature flag decision for the given flag key using the async + * `decideAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support. + * + * Client-side only — `decideAsync` returns a Promise which cannot resolve + * during server render. + * + * @param flagKey - The feature flag key to evaluate + * @param config - Optional configuration (decideOptions) + */ +export function useDecideAsync(flagKey: string, config?: UseDecideConfig): UseDecideAsyncResult { + const { store, client } = useOptimizelyContext(); + const decideOptions = useStableArray(config?.decideOptions); + const state = useProviderState(store); + + // --- Forced decision subscription --- + const [fdVersion, setFdVersion] = useState(0); + useEffect(() => { + return store.subscribeForcedDecision(flagKey, () => { + setFdVersion((v) => v + 1); + }); + }, [store, flagKey]); + + // --- Async decision state --- + const [asyncState, setAsyncState] = useState<{ + decision: OptimizelyDecision | null; + error: Error | null; + isLoading: boolean; + }>({ decision: null, error: null, isLoading: true }); + + // --- Async decision effect --- + useEffect(() => { + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + // Store-level error — no async call needed + if (error) { + setAsyncState({ decision: null, error, isLoading: false }); + return; + } + + // Store not ready — stay in loading + if (!hasConfig || userContext === null) { + setAsyncState({ decision: null, error: null, isLoading: true }); + return; + } + + // Store is ready — fire async decision + let cancelled = false; + setAsyncState((prev) => { + if (prev.isLoading && prev.error === null && prev.decision === null) return prev; + return { decision: null, error: null, isLoading: true }; + }); + + userContext.decideAsync(flagKey, decideOptions).then( + (decision) => { + if (!cancelled) { + setAsyncState({ decision, error: null, isLoading: false }); + } + }, + (err) => { + if (!cancelled) { + setAsyncState({ + decision: null, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }); + } + } + ); + + return () => { + cancelled = true; + }; + }, [state, fdVersion, client, flagKey, decideOptions]); + + return asyncState as UseDecideAsyncResult; +} diff --git a/src/index.ts b/src/index.ts index 8cbfacb..984a923 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,4 +39,5 @@ export { useDecide, useDecideForKeys, useDecideAll, + useDecideAsync, } from './hooks/index'; From bfe2941b736265d733e13dc69262ed81dbf66d1e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:41:54 +0600 Subject: [PATCH 2/4] [FSSDK-12296] decideForKeysAsync, decideAllAsync impl. --- src/hooks/index.ts | 3 + src/hooks/useDecideAllAsync.ts | 103 ++++++ src/hooks/useDecideAsync.spec.tsx | 70 +--- src/hooks/useDecideAsync.ts | 3 + src/hooks/useDecideForKeysAsync.spec.tsx | 415 +++++++++++++++++++++++ src/hooks/useDecideForKeysAsync.ts | 110 ++++++ src/index.ts | 2 + 7 files changed, 653 insertions(+), 53 deletions(-) create mode 100644 src/hooks/useDecideAllAsync.ts create mode 100644 src/hooks/useDecideForKeysAsync.spec.tsx create mode 100644 src/hooks/useDecideForKeysAsync.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7edfec0..331fc69 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -24,3 +24,6 @@ export type { UseDecideMultiResult } from './useDecideForKeys'; export { useDecideAll } from './useDecideAll'; export { useDecideAsync } from './useDecideAsync'; export type { UseDecideAsyncResult } from './useDecideAsync'; +export { useDecideForKeysAsync } from './useDecideForKeysAsync'; +export type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync'; +export { useDecideAllAsync } from './useDecideAllAsync'; diff --git a/src/hooks/useDecideAllAsync.ts b/src/hooks/useDecideAllAsync.ts new file mode 100644 index 0000000..b0bd720 --- /dev/null +++ b/src/hooks/useDecideAllAsync.ts @@ -0,0 +1,103 @@ +/** + * 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 { useEffect, useState } from 'react'; +import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useProviderState } from './useProviderState'; +import { useStableArray } from './useStableArray'; +import type { UseDecideConfig } from './useDecide'; +import type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync'; + +/** + * Returns feature flag decisions for all flags using the async + * `decideAllAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support. + * + * Client-side only — `decideAllAsync` returns a Promise which cannot resolve + * during server render. + * + * @param config - Optional configuration (decideOptions) + */ +export function useDecideAllAsync(config?: UseDecideConfig): UseDecideMultiAsyncResult { + const { store, client } = useOptimizelyContext(); + const decideOptions = useStableArray(config?.decideOptions); + const state = useProviderState(store); + + // --- Forced decision subscription — any flag key --- + const [fdVersion, setFdVersion] = useState(0); + useEffect(() => { + return store.subscribeAllForcedDecisions(() => setFdVersion((v) => v + 1)); + }, [store]); + + // --- Async decision state --- + const [asyncState, setAsyncState] = useState<{ + decisions: Record | Record; + error: Error | null; + isLoading: boolean; + }>({ decisions: {} as Record, error: null, isLoading: true }); + + // --- Async decision effect --- + useEffect(() => { + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + // Store-level error — no async call needed + if (error) { + setAsyncState({ decisions: {} as Record, error, isLoading: false }); + return; + } + + // Store not ready — stay in loading + if (!hasConfig || userContext === null) { + setAsyncState({ decisions: {} as Record, error: null, isLoading: true }); + return; + } + + // Store is ready — fire async decision + let cancelled = false; + // Reset to loading before firing the async call. + // If already in the initial loading state, returns `prev` as-is to + // skip a redundant re-render on first mount. + setAsyncState((prev) => { + if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev; + return { decisions: {} as Record, error: null, isLoading: true }; + }); + + userContext.decideAllAsync(decideOptions).then( + (decisions) => { + if (!cancelled) { + setAsyncState({ decisions, error: null, isLoading: false }); + } + }, + (err) => { + if (!cancelled) { + setAsyncState({ + decisions: {} as Record, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }); + } + } + ); + + return () => { + cancelled = true; + }; + }, [state, fdVersion, client, decideOptions]); + + return asyncState as UseDecideMultiAsyncResult; +} diff --git a/src/hooks/useDecideAsync.spec.tsx b/src/hooks/useDecideAsync.spec.tsx index 357c56e..7b22677 100644 --- a/src/hooks/useDecideAsync.spec.tsx +++ b/src/hooks/useDecideAsync.spec.tsx @@ -99,37 +99,40 @@ describe('useDecideAsync', () => { expect(result.current.error).toBeNull(); }); - it('should return decision when async call resolves', async () => { + it('should not trigger a redundant re-render when mounting with store already ready', () => { mockClient = createMockClient(true); const mockUserContext = createMockUserContext(); + (mockUserContext.decideAsync as ReturnType).mockReturnValue(new Promise(() => {})); store.setUserContext(mockUserContext); + let renderCount = 0; const wrapper = createWrapper(store, mockClient); - const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.decision).toBe(MOCK_DECISION); - expect(result.current.error).toBeNull(); + renderHook( + () => { + renderCount++; + return useDecideAsync('flag_1'); + }, + { wrapper } + ); + + // Should render once (initial), not twice (initial + redundant setState) + expect(renderCount).toBe(1); }); - it('should pass decideOptions to decideAsync()', async () => { + it('should return decision when async call resolves', async () => { mockClient = createMockClient(true); const mockUserContext = createMockUserContext(); store.setUserContext(mockUserContext); - const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; - const wrapper = createWrapper(store, mockClient); - const { result } = renderHook(() => useDecideAsync('flag_1', { decideOptions }), { wrapper }); + const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', decideOptions); + expect(result.current.decision).toBe(MOCK_DECISION); + expect(result.current.error).toBeNull(); }); it('should return error when decideAsync() rejects', async () => { @@ -280,34 +283,6 @@ describe('useDecideAsync', () => { expect(mockUserContext.decideAsync).toHaveBeenCalledWith('flag_1', newOptions); }); - it('should re-fire async call when user context changes (store state)', async () => { - mockClient = createMockClient(true); - const mockUserContext1 = createMockUserContext(); - store.setUserContext(mockUserContext1); - - const wrapper = createWrapper(store, mockClient); - const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.decision).toBe(MOCK_DECISION); - expect(mockUserContext1.decideAsync).toHaveBeenCalledTimes(1); - - // Now change to a different user context - const updatedDecision: OptimizelyDecision = { ...MOCK_DECISION, variationKey: 'variation_2' }; - const mockUserContext2 = createMockUserContext(); - (mockUserContext2.decideAsync as ReturnType).mockResolvedValue(updatedDecision); - - await act(async () => { - store.setUserContext(mockUserContext2); - }); - - expect(result.current.decision).toBe(updatedDecision); - expect(mockUserContext2.decideAsync).toHaveBeenCalledTimes(1); - }); - it('should re-fire async call on config update', async () => { const mockUserContext = createMockUserContext(); const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); @@ -435,15 +410,4 @@ describe('useDecideAsync', () => { expect(unsubscribeFdSpy).toHaveBeenCalledTimes(1); }); }); - - it('should not call decideAsync() while loading', () => { - const mockUserContext = createMockUserContext(); - // Config not available, user context set - store.setUserContext(mockUserContext); - - const wrapper = createWrapper(store, mockClient); - renderHook(() => useDecideAsync('flag_1'), { wrapper }); - - expect(mockUserContext.decideAsync).not.toHaveBeenCalled(); - }); }); diff --git a/src/hooks/useDecideAsync.ts b/src/hooks/useDecideAsync.ts index 69cf6d9..d3f723f 100644 --- a/src/hooks/useDecideAsync.ts +++ b/src/hooks/useDecideAsync.ts @@ -76,6 +76,9 @@ export function useDecideAsync(flagKey: string, config?: UseDecideConfig): UseDe // Store is ready — fire async decision let cancelled = false; + // Reset to loading before firing the async call. + // If already in the initial loading state, returns `prev` as-is to + // skip a redundant re-render on first mount. setAsyncState((prev) => { if (prev.isLoading && prev.error === null && prev.decision === null) return prev; return { decision: null, error: null, isLoading: true }; diff --git a/src/hooks/useDecideForKeysAsync.spec.tsx b/src/hooks/useDecideForKeysAsync.spec.tsx new file mode 100644 index 0000000..af3bbf2 --- /dev/null +++ b/src/hooks/useDecideForKeysAsync.spec.tsx @@ -0,0 +1,415 @@ +/** + * 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, describe, it, expect, beforeEach } from 'vitest'; +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { ProviderStateStore } from '../provider/index'; +import { useDecideForKeysAsync } from './useDecideForKeysAsync'; +import { + MOCK_DECISIONS, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; + +describe('useDecideForKeysAsync', () => { + let store: ProviderStateStore; + let mockClient: Client; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + mockClient = createMockClient(); + }); + + // --- Store state tests --- + + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDecideForKeysAsync(['flag_1'])); + }).toThrow('Optimizely hooks must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return isLoading: true when no config and no user context', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.decisions).toEqual({}); + }); + + it('should return isLoading: true when config is available but no user context', () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return error from store with isLoading: false', async () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const testError = new Error('SDK initialization failed'); + await act(async () => { + store.setError(testError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(testError); + expect(result.current.decisions).toEqual({}); + }); + + it('should return isLoading: true while async call is in-flight', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideForKeysAsync as ReturnType).mockReturnValue(new Promise(() => {})); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.decisions).toEqual({}); + expect(result.current.error).toBeNull(); + }); + + it('should return decisions when async call resolves', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + expect(result.current.error).toBeNull(); + }); + + it('should return error when decideForKeysAsync() rejects', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + const asyncError = new Error('CMAB request failed'); + (mockUserContext.decideForKeysAsync as ReturnType).mockRejectedValue(asyncError); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(asyncError); + expect(result.current.decisions).toEqual({}); + }); + + it('should wrap non-Error rejection in Error object', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideForKeysAsync as ReturnType).mockRejectedValue('string error'); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error!.message).toBe('string error'); + expect(result.current.decisions).toEqual({}); + }); + + // --- Race condition tests --- + + it('should discard stale result when flagKeys change before resolve', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + + let resolveFirst: (d: Record) => void; + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + const flag2Only: Record = { + flag_2: MOCK_DECISIONS['flag_2'], + }; + + (mockUserContext.decideForKeysAsync as ReturnType) + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce(flag2Only); + + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ keys }) => useDecideForKeysAsync(keys), { + wrapper, + initialProps: { keys: ['flag_1'] }, + }); + + // Change keys before first resolves + rerender({ keys: ['flag_2'] }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(flag2Only); + + // Now resolve the stale first promise — should be ignored + await act(async () => { + resolveFirst!({ flag_1: MOCK_DECISIONS['flag_1'] }); + }); + + expect(result.current.decisions).toEqual(flag2Only); + }); + + it('should discard stale result when store state changes before resolve', async () => { + mockClient = createMockClient(true); + const mockUserContext1 = createMockUserContext(); + + let resolveFirst: (d: Record) => void; + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + (mockUserContext1.decideForKeysAsync as ReturnType).mockReturnValue(firstPromise); + store.setUserContext(mockUserContext1); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + // New user context arrives — cancels first async call + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated' }, + flag_2: MOCK_DECISIONS['flag_2'], + }; + const mockUserContext2 = createMockUserContext(); + (mockUserContext2.decideForKeysAsync as ReturnType).mockResolvedValue(updatedDecisions); + + await act(async () => { + store.setUserContext(mockUserContext2); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(updatedDecisions); + + // Resolve the stale first promise — should be ignored + await act(async () => { + resolveFirst!(MOCK_DECISIONS); + }); + + expect(result.current.decisions).toEqual(updatedDecisions); + }); + + // --- Re-evaluation tests --- + + it('should re-fire async call when decideOptions change', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook( + ({ options }) => useDecideForKeysAsync(['flag_1'], { decideOptions: options }), + { + wrapper, + initialProps: { options: undefined as OptimizelyDecideOption[] | undefined }, + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const newOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + (mockUserContext.decideForKeysAsync as ReturnType).mockClear(); + + rerender({ options: newOptions }); + + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledWith(['flag_1'], newOptions); + }); + + it('should re-fire async call on config update', async () => { + const mockUserContext = createMockUserContext(); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); + + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual({ flag_1: MOCK_DECISIONS['flag_1'] }); + + const callCountBeforeUpdate = (mockUserContext.decideForKeysAsync as ReturnType).mock.calls.length; + + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated_variation' }, + }; + (mockUserContext.decideForKeysAsync as ReturnType).mockResolvedValue(updatedDecisions); + + await act(async () => { + fireConfigUpdate(); + }); + + expect(result.current.decisions).toEqual(updatedDecisions); + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.isLoading).toBe(false); + }); + + // --- Forced decision tests --- + + describe('forced decision reactivity', () => { + it('should re-fire async call when setForcedDecision is called for a key in the array', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledTimes(1); + + const forcedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'forced_variation' }, + flag_2: MOCK_DECISIONS['flag_2'], + }; + (mockUserContext.decideForKeysAsync as ReturnType).mockResolvedValue(forcedDecisions); + + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledTimes(2); + + await waitFor(() => { + expect(result.current.decisions).toEqual(forcedDecisions); + }); + }); + + it('should NOT re-fire for a flagKey NOT in the array', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + (mockUserContext.decideForKeysAsync as ReturnType).mockClear(); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_2' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideForKeysAsync).not.toHaveBeenCalled(); + }); + + it('should re-fire when removeAllForcedDecisions is called', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeysAsync(['flag_1']), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Set a forced decision to register the flagKey + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledTimes(2); + + await act(async () => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decideForKeysAsync).toHaveBeenCalledTimes(3); + }); + + it('should re-subscribe forced decisions when keys change', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const subscribeFdSpy = vi.spyOn(store, 'subscribeForcedDecision'); + + const wrapper = createWrapper(store, mockClient); + const { rerender } = renderHook(({ keys }) => useDecideForKeysAsync(keys), { + wrapper, + initialProps: { keys: ['flag_1'] }, + }); + + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_1', expect.any(Function)); + + rerender({ keys: ['flag_2', 'flag_3'] }); + + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_2', expect.any(Function)); + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_3', expect.any(Function)); + }); + + it('should unsubscribe forced decision listeners on unmount', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const unsubscribeSpy = vi.fn(); + vi.spyOn(store, 'subscribeForcedDecision').mockReturnValue(unsubscribeSpy); + + const wrapper = createWrapper(store, mockClient); + const { unmount } = renderHook(() => useDecideForKeysAsync(['flag_1', 'flag_2']), { wrapper }); + + unmount(); + + // One unsubscribe call per key + expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/hooks/useDecideForKeysAsync.ts b/src/hooks/useDecideForKeysAsync.ts new file mode 100644 index 0000000..3cae827 --- /dev/null +++ b/src/hooks/useDecideForKeysAsync.ts @@ -0,0 +1,110 @@ +/** + * 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 { useEffect, useState } from 'react'; +import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useProviderState } from './useProviderState'; +import { useStableArray } from './useStableArray'; +import type { UseDecideConfig } from './useDecide'; + +export type UseDecideMultiAsyncResult = + | { isLoading: true; error: null; decisions: Record } + | { isLoading: false; error: Error; decisions: Record } + | { isLoading: false; error: null; decisions: Record }; + +/** + * Returns feature flag decisions for the given flag keys using the async + * `decideForKeysAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support. + * + * Client-side only — `decideForKeysAsync` returns a Promise which cannot resolve + * during server render. + * + * @param flagKeys - The feature flag keys to evaluate + * @param config - Optional configuration (decideOptions) + */ +export function useDecideForKeysAsync(flagKeys: string[], config?: UseDecideConfig): UseDecideMultiAsyncResult { + const { store, client } = useOptimizelyContext(); + const stableKeys = useStableArray(flagKeys); + const decideOptions = useStableArray(config?.decideOptions); + const state = useProviderState(store); + + // --- Forced decision subscription — per-key with shared version counter --- + const [fdVersion, setFdVersion] = useState(0); + useEffect(() => { + const unsubscribes = stableKeys.map((key) => store.subscribeForcedDecision(key, () => setFdVersion((v) => v + 1))); + return () => unsubscribes.forEach((unsub) => unsub()); + }, [store, stableKeys]); + + // --- Async decision state --- + const [asyncState, setAsyncState] = useState<{ + decisions: Record | Record; + error: Error | null; + isLoading: boolean; + }>({ decisions: {} as Record, error: null, isLoading: true }); + + // --- Async decision effect --- + useEffect(() => { + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + // Store-level error — no async call needed + if (error) { + setAsyncState({ decisions: {} as Record, error, isLoading: false }); + return; + } + + // Store not ready — stay in loading + if (!hasConfig || userContext === null) { + setAsyncState({ decisions: {} as Record, error: null, isLoading: true }); + return; + } + + // Store is ready — fire async decision + let cancelled = false; + // Reset to loading before firing the async call. + // If already in the initial loading state, returns `prev` as-is to + // skip a redundant re-render on first mount. + setAsyncState((prev) => { + if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev; + return { decisions: {} as Record, error: null, isLoading: true }; + }); + + userContext.decideForKeysAsync(stableKeys, decideOptions).then( + (decisions) => { + if (!cancelled) { + setAsyncState({ decisions, error: null, isLoading: false }); + } + }, + (err) => { + if (!cancelled) { + setAsyncState({ + decisions: {} as Record, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }); + } + } + ); + + return () => { + cancelled = true; + }; + }, [state, fdVersion, client, stableKeys, decideOptions]); + + return asyncState as UseDecideMultiAsyncResult; +} diff --git a/src/index.ts b/src/index.ts index 984a923..9716b69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,4 +40,6 @@ export { useDecideForKeys, useDecideAll, useDecideAsync, + useDecideForKeysAsync, + useDecideAllAsync, } from './hooks/index'; From b54e1e256d4e8eefc9cbe41bd17516cf72773028 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:02:56 +0600 Subject: [PATCH 3/4] [FSSDK-12296] decideAllAsync tests addition --- src/hooks/useDecideAllAsync.spec.tsx | 365 +++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/hooks/useDecideAllAsync.spec.tsx diff --git a/src/hooks/useDecideAllAsync.spec.tsx b/src/hooks/useDecideAllAsync.spec.tsx new file mode 100644 index 0000000..f6a0083 --- /dev/null +++ b/src/hooks/useDecideAllAsync.spec.tsx @@ -0,0 +1,365 @@ +/** + * 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, describe, it, expect, beforeEach } from 'vitest'; +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { ProviderStateStore } from '../provider/index'; +import { useDecideAllAsync } from './useDecideAllAsync'; +import { + MOCK_DECISIONS, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; + +describe('useDecideAllAsync', () => { + let store: ProviderStateStore; + let mockClient: Client; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + mockClient = createMockClient(); + }); + + // --- Store state tests --- + + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDecideAllAsync()); + }).toThrow('Optimizely hooks must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return isLoading: true when no config and no user context', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.decisions).toEqual({}); + }); + + it('should return isLoading: true when config is available but no user context', () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return error from store with isLoading: false', async () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const testError = new Error('SDK initialization failed'); + await act(async () => { + store.setError(testError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(testError); + expect(result.current.decisions).toEqual({}); + }); + + it('should return isLoading: true while async call is in-flight', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideAllAsync as ReturnType).mockReturnValue(new Promise(() => {})); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.decisions).toEqual({}); + expect(result.current.error).toBeNull(); + }); + + it('should not trigger a redundant re-render when mounting with store already ready', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideAllAsync as ReturnType).mockReturnValue(new Promise(() => {})); + store.setUserContext(mockUserContext); + + let renderCount = 0; + const wrapper = createWrapper(store, mockClient); + renderHook( + () => { + renderCount++; + return useDecideAllAsync(); + }, + { wrapper } + ); + + expect(renderCount).toBe(1); + }); + + it('should return decisions when async call resolves', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + expect(result.current.error).toBeNull(); + }); + + it('should return error when decideAllAsync() rejects', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + const asyncError = new Error('CMAB request failed'); + (mockUserContext.decideAllAsync as ReturnType).mockRejectedValue(asyncError); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(asyncError); + expect(result.current.decisions).toEqual({}); + }); + + it('should wrap non-Error rejection in Error object', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + (mockUserContext.decideAllAsync as ReturnType).mockRejectedValue('string error'); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error!.message).toBe('string error'); + expect(result.current.decisions).toEqual({}); + }); + + // --- Race condition tests --- + + it('should discard stale result when store state changes before resolve', async () => { + mockClient = createMockClient(true); + const mockUserContext1 = createMockUserContext(); + + let resolveFirst: (d: Record) => void; + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + (mockUserContext1.decideAllAsync as ReturnType).mockReturnValue(firstPromise); + store.setUserContext(mockUserContext1); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + // New user context arrives — cancels first async call + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated' }, + }; + const mockUserContext2 = createMockUserContext(); + (mockUserContext2.decideAllAsync as ReturnType).mockResolvedValue(updatedDecisions); + + await act(async () => { + store.setUserContext(mockUserContext2); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.decisions).toEqual(updatedDecisions); + + // Resolve the stale first promise — should be ignored + await act(async () => { + resolveFirst!(MOCK_DECISIONS); + }); + + expect(result.current.decisions).toEqual(updatedDecisions); + }); + + // --- Re-evaluation tests --- + + it('should re-fire async call when decideOptions change', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ options }) => useDecideAllAsync({ decideOptions: options }), { + wrapper, + initialProps: { options: undefined as OptimizelyDecideOption[] | undefined }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const newOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + (mockUserContext.decideAllAsync as ReturnType).mockClear(); + + rerender({ options: newOptions }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledWith(newOptions); + }); + + it('should re-fire async call on config update', async () => { + const mockUserContext = createMockUserContext(); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); + + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + + const callCountBeforeUpdate = (mockUserContext.decideAllAsync as ReturnType).mock.calls.length; + + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated_variation' }, + }; + (mockUserContext.decideAllAsync as ReturnType).mockResolvedValue(updatedDecisions); + + await act(async () => { + fireConfigUpdate(); + }); + + expect(result.current.decisions).toEqual(updatedDecisions); + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.isLoading).toBe(false); + }); + + // --- Forced decision tests --- + + describe('forced decision reactivity', () => { + it('should re-fire on any setForcedDecision via subscribeAllForcedDecisions', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(1); + + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2); + + // A different flag also triggers re-evaluation + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_99' }, { variationKey: 'v99' }); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3); + }); + + it('should re-fire on removeForcedDecision', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2); + + await act(async () => { + mockUserContext.removeForcedDecision({ flagKey: 'flag_1' }); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3); + }); + + it('should re-fire on removeAllForcedDecisions', async () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAllAsync(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2); + + await act(async () => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3); + }); + + it('should unsubscribe subscribeAllForcedDecisions listener on unmount', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const unsubscribeAllFdSpy = vi.fn(); + const subscribeAllFdSpy = vi.spyOn(store, 'subscribeAllForcedDecisions').mockReturnValue(unsubscribeAllFdSpy); + + const wrapper = createWrapper(store, mockClient); + const { unmount } = renderHook(() => useDecideAllAsync(), { wrapper }); + + expect(subscribeAllFdSpy).toHaveBeenCalledTimes(1); + + unmount(); + + expect(unsubscribeAllFdSpy).toHaveBeenCalledTimes(1); + }); + }); +}); From d2b8c8af03c1e16d6284ab13f7213990e0c72e43 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:20:51 +0600 Subject: [PATCH 4/4] [FSSDK-12296] common code abstraction --- src/hooks/useAsyncDecision.ts | 104 +++++++++++++++++++++++++++++ src/hooks/useDecideAllAsync.ts | 65 +++--------------- src/hooks/useDecideAsync.ts | 66 +++--------------- src/hooks/useDecideForKeysAsync.ts | 68 +++---------------- 4 files changed, 132 insertions(+), 171 deletions(-) create mode 100644 src/hooks/useAsyncDecision.ts diff --git a/src/hooks/useAsyncDecision.ts b/src/hooks/useAsyncDecision.ts new file mode 100644 index 0000000..3a9fbeb --- /dev/null +++ b/src/hooks/useAsyncDecision.ts @@ -0,0 +1,104 @@ +/** + * 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 { useEffect, useState } from 'react'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +import type { Client } from '@optimizely/optimizely-sdk'; +import type { ProviderState } from '../provider/index'; + +interface AsyncState { + result: TResult; + error: Error | null; + isLoading: boolean; +} + +/** + * Shared async decision state machine used by useDecideAsync, + * useDecideForKeysAsync, and useDecideAllAsync. + * + * Handles: loading state, error propagation, cancellation of stale promises, + * and redundant re-render avoidance on first mount. + * + * @param state - Provider state from useProviderState + * @param client - Optimizely client instance + * @param fdVersion - Forced decision version counter (triggers re-evaluation) + * @param emptyResult - Default/empty result value (null for single, {} for multi) + * @param execute - Callback that performs the async SDK call + */ +export function useAsyncDecision( + state: ProviderState, + client: Client, + fdVersion: number, + emptyResult: TResult, + execute: (userContext: OptimizelyUserContext) => Promise +): AsyncState { + const [asyncState, setAsyncState] = useState>({ + result: emptyResult, + error: null, + isLoading: true, + }); + + useEffect(() => { + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + // Store-level error — no async call needed + if (error) { + setAsyncState({ result: emptyResult, error, isLoading: false }); + return; + } + + // Store not ready — stay in loading + if (!hasConfig || userContext === null) { + setAsyncState({ result: emptyResult, error: null, isLoading: true }); + return; + } + + // Store is ready — fire async decision + let cancelled = false; + // Reset to loading before firing the async call. + // If already in the initial loading state, returns `prev` as-is to + // skip a redundant re-render on first mount. + setAsyncState((prev) => { + if (prev.isLoading && prev.error === null && prev.result === emptyResult) return prev; + return { result: emptyResult, error: null, isLoading: true }; + }); + + execute(userContext).then( + (result) => { + if (!cancelled) { + setAsyncState({ result, error: null, isLoading: false }); + } + }, + (err) => { + if (!cancelled) { + setAsyncState({ + result: emptyResult, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }); + } + } + ); + + return () => { + cancelled = true; + }; + }, [state, fdVersion, client, execute, emptyResult]); + + return asyncState; +} diff --git a/src/hooks/useDecideAllAsync.ts b/src/hooks/useDecideAllAsync.ts index b0bd720..8505e1a 100644 --- a/src/hooks/useDecideAllAsync.ts +++ b/src/hooks/useDecideAllAsync.ts @@ -14,15 +14,18 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; -import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; import { useOptimizelyContext } from './useOptimizelyContext'; import { useProviderState } from './useProviderState'; import { useStableArray } from './useStableArray'; +import { useAsyncDecision } from './useAsyncDecision'; import type { UseDecideConfig } from './useDecide'; import type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync'; +const EMPTY_DECISIONS = {} as Record; + /** * Returns feature flag decisions for all flags using the async * `decideAllAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support. @@ -43,61 +46,9 @@ export function useDecideAllAsync(config?: UseDecideConfig): UseDecideMultiAsync return store.subscribeAllForcedDecisions(() => setFdVersion((v) => v + 1)); }, [store]); - // --- Async decision state --- - const [asyncState, setAsyncState] = useState<{ - decisions: Record | Record; - error: Error | null; - isLoading: boolean; - }>({ decisions: {} as Record, error: null, isLoading: true }); - - // --- Async decision effect --- - useEffect(() => { - const { userContext, error } = state; - const hasConfig = client.getOptimizelyConfig() !== null; - - // Store-level error — no async call needed - if (error) { - setAsyncState({ decisions: {} as Record, error, isLoading: false }); - return; - } - - // Store not ready — stay in loading - if (!hasConfig || userContext === null) { - setAsyncState({ decisions: {} as Record, error: null, isLoading: true }); - return; - } - - // Store is ready — fire async decision - let cancelled = false; - // Reset to loading before firing the async call. - // If already in the initial loading state, returns `prev` as-is to - // skip a redundant re-render on first mount. - setAsyncState((prev) => { - if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev; - return { decisions: {} as Record, error: null, isLoading: true }; - }); - - userContext.decideAllAsync(decideOptions).then( - (decisions) => { - if (!cancelled) { - setAsyncState({ decisions, error: null, isLoading: false }); - } - }, - (err) => { - if (!cancelled) { - setAsyncState({ - decisions: {} as Record, - error: err instanceof Error ? err : new Error(String(err)), - isLoading: false, - }); - } - } - ); + const execute = useCallback((uc: OptimizelyUserContext) => uc.decideAllAsync(decideOptions), [decideOptions]); - return () => { - cancelled = true; - }; - }, [state, fdVersion, client, decideOptions]); + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute); - return asyncState as UseDecideMultiAsyncResult; + return { decisions: result, error, isLoading } as UseDecideMultiAsyncResult; } diff --git a/src/hooks/useDecideAsync.ts b/src/hooks/useDecideAsync.ts index d3f723f..e79bf6b 100644 --- a/src/hooks/useDecideAsync.ts +++ b/src/hooks/useDecideAsync.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; -import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; import { useOptimizelyContext } from './useOptimizelyContext'; import { useProviderState } from './useProviderState'; import { useStableArray } from './useStableArray'; +import { useAsyncDecision } from './useAsyncDecision'; import type { UseDecideConfig } from './useDecide'; export type UseDecideAsyncResult = @@ -50,61 +51,12 @@ export function useDecideAsync(flagKey: string, config?: UseDecideConfig): UseDe }); }, [store, flagKey]); - // --- Async decision state --- - const [asyncState, setAsyncState] = useState<{ - decision: OptimizelyDecision | null; - error: Error | null; - isLoading: boolean; - }>({ decision: null, error: null, isLoading: true }); + const execute = useCallback( + (uc: OptimizelyUserContext) => uc.decideAsync(flagKey, decideOptions), + [flagKey, decideOptions] + ); - // --- Async decision effect --- - useEffect(() => { - const { userContext, error } = state; - const hasConfig = client.getOptimizelyConfig() !== null; - - // Store-level error — no async call needed - if (error) { - setAsyncState({ decision: null, error, isLoading: false }); - return; - } - - // Store not ready — stay in loading - if (!hasConfig || userContext === null) { - setAsyncState({ decision: null, error: null, isLoading: true }); - return; - } - - // Store is ready — fire async decision - let cancelled = false; - // Reset to loading before firing the async call. - // If already in the initial loading state, returns `prev` as-is to - // skip a redundant re-render on first mount. - setAsyncState((prev) => { - if (prev.isLoading && prev.error === null && prev.decision === null) return prev; - return { decision: null, error: null, isLoading: true }; - }); - - userContext.decideAsync(flagKey, decideOptions).then( - (decision) => { - if (!cancelled) { - setAsyncState({ decision, error: null, isLoading: false }); - } - }, - (err) => { - if (!cancelled) { - setAsyncState({ - decision: null, - error: err instanceof Error ? err : new Error(String(err)), - isLoading: false, - }); - } - } - ); - - return () => { - cancelled = true; - }; - }, [state, fdVersion, client, flagKey, decideOptions]); + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, null, execute); - return asyncState as UseDecideAsyncResult; + return { decision: result, error, isLoading } as UseDecideAsyncResult; } diff --git a/src/hooks/useDecideForKeysAsync.ts b/src/hooks/useDecideForKeysAsync.ts index 3cae827..8cf4426 100644 --- a/src/hooks/useDecideForKeysAsync.ts +++ b/src/hooks/useDecideForKeysAsync.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; -import type { OptimizelyDecision } from '@optimizely/optimizely-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; import { useOptimizelyContext } from './useOptimizelyContext'; import { useProviderState } from './useProviderState'; import { useStableArray } from './useStableArray'; +import { useAsyncDecision } from './useAsyncDecision'; import type { UseDecideConfig } from './useDecide'; export type UseDecideMultiAsyncResult = @@ -27,6 +28,8 @@ export type UseDecideMultiAsyncResult = | { isLoading: false; error: Error; decisions: Record } | { isLoading: false; error: null; decisions: Record }; +const EMPTY_DECISIONS = {} as Record; + /** * Returns feature flag decisions for the given flag keys using the async * `decideForKeysAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support. @@ -50,61 +53,12 @@ export function useDecideForKeysAsync(flagKeys: string[], config?: UseDecideConf return () => unsubscribes.forEach((unsub) => unsub()); }, [store, stableKeys]); - // --- Async decision state --- - const [asyncState, setAsyncState] = useState<{ - decisions: Record | Record; - error: Error | null; - isLoading: boolean; - }>({ decisions: {} as Record, error: null, isLoading: true }); - - // --- Async decision effect --- - useEffect(() => { - const { userContext, error } = state; - const hasConfig = client.getOptimizelyConfig() !== null; - - // Store-level error — no async call needed - if (error) { - setAsyncState({ decisions: {} as Record, error, isLoading: false }); - return; - } - - // Store not ready — stay in loading - if (!hasConfig || userContext === null) { - setAsyncState({ decisions: {} as Record, error: null, isLoading: true }); - return; - } - - // Store is ready — fire async decision - let cancelled = false; - // Reset to loading before firing the async call. - // If already in the initial loading state, returns `prev` as-is to - // skip a redundant re-render on first mount. - setAsyncState((prev) => { - if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev; - return { decisions: {} as Record, error: null, isLoading: true }; - }); - - userContext.decideForKeysAsync(stableKeys, decideOptions).then( - (decisions) => { - if (!cancelled) { - setAsyncState({ decisions, error: null, isLoading: false }); - } - }, - (err) => { - if (!cancelled) { - setAsyncState({ - decisions: {} as Record, - error: err instanceof Error ? err : new Error(String(err)), - isLoading: false, - }); - } - } - ); + const execute = useCallback( + (uc: OptimizelyUserContext) => uc.decideForKeysAsync(stableKeys, decideOptions), + [stableKeys, decideOptions] + ); - return () => { - cancelled = true; - }; - }, [state, fdVersion, client, stableKeys, decideOptions]); + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute); - return asyncState as UseDecideMultiAsyncResult; + return { decisions: result, error, isLoading } as UseDecideMultiAsyncResult; }