diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 988ca53..331fc69 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,8 @@ 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'; +export { useDecideForKeysAsync } from './useDecideForKeysAsync'; +export type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync'; +export { useDecideAllAsync } from './useDecideAllAsync'; 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/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 new file mode 100644 index 0000000..7b22677 --- /dev/null +++ b/src/hooks/useDecideAsync.spec.tsx @@ -0,0 +1,413 @@ +/** + * 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 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); + renderHook( + () => { + renderCount++; + return useDecideAsync('flag_1'); + }, + { wrapper } + ); + + // Should render once (initial), not twice (initial + redundant setState) + expect(renderCount).toBe(1); + }); + + 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 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 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); + }); + }); +}); diff --git a/src/hooks/useDecideAsync.ts b/src/hooks/useDecideAsync.ts new file mode 100644 index 0000000..d3f723f --- /dev/null +++ b/src/hooks/useDecideAsync.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 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; + // 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]); + + return asyncState as UseDecideAsyncResult; +} 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 8cbfacb..9716b69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,4 +39,7 @@ export { useDecide, useDecideForKeys, useDecideAll, + useDecideAsync, + useDecideForKeysAsync, + useDecideAllAsync, } from './hooks/index';