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/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.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); + }); + }); +}); diff --git a/src/hooks/useDecideAllAsync.ts b/src/hooks/useDecideAllAsync.ts new file mode 100644 index 0000000..8505e1a --- /dev/null +++ b/src/hooks/useDecideAllAsync.ts @@ -0,0 +1,54 @@ +/** + * 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 { 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. + * + * 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]); + + const execute = useCallback((uc: OptimizelyUserContext) => uc.decideAllAsync(decideOptions), [decideOptions]); + + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute); + + return { decisions: result, error, isLoading } 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..e79bf6b --- /dev/null +++ b/src/hooks/useDecideAsync.ts @@ -0,0 +1,62 @@ +/** + * 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 { 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 = + | { 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]); + + const execute = useCallback( + (uc: OptimizelyUserContext) => uc.decideAsync(flagKey, decideOptions), + [flagKey, decideOptions] + ); + + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, null, execute); + + return { decision: result, error, isLoading } 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..8cf4426 --- /dev/null +++ b/src/hooks/useDecideForKeysAsync.ts @@ -0,0 +1,64 @@ +/** + * 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 { 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 = + | { isLoading: true; error: null; decisions: Record } + | { 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. + * + * 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]); + + const execute = useCallback( + (uc: OptimizelyUserContext) => uc.decideForKeysAsync(stableKeys, decideOptions), + [stableKeys, decideOptions] + ); + + const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute); + + return { decisions: result, error, isLoading } 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';