diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2ef9b8b..988ca53 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -19,3 +19,6 @@ export { useOptimizelyUserContext } from './useOptimizelyUserContext'; export type { UseOptimizelyUserContextResult } from './useOptimizelyUserContext'; export { useDecide } from './useDecide'; export type { UseDecideConfig, UseDecideResult } from './useDecide'; +export { useDecideForKeys } from './useDecideForKeys'; +export type { UseDecideMultiResult } from './useDecideForKeys'; +export { useDecideAll } from './useDecideAll'; diff --git a/src/hooks/testUtils.tsx b/src/hooks/testUtils.tsx new file mode 100644 index 0000000..36c3375 --- /dev/null +++ b/src/hooks/testUtils.tsx @@ -0,0 +1,148 @@ +/** + * 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 } from 'vitest'; +import React from 'react'; +import { OptimizelyContext, ProviderStateStore, OptimizelyProvider } from '../provider/index'; +import { REACT_CLIENT_META } from '../client/index'; +import type { OptimizelyUserContext, OptimizelyDecision, Client } from '@optimizely/optimizely-sdk'; +import type { OptimizelyContextValue } from '../provider/index'; + +export const MOCK_DECISION: OptimizelyDecision = { + variationKey: 'variation_1', + enabled: true, + variables: { color: 'red' }, + ruleKey: 'rule_1', + flagKey: 'flag_1', + userContext: {} as OptimizelyUserContext, + reasons: [], +}; + +export const MOCK_DECISIONS: Record = { + flag_1: MOCK_DECISION, + flag_2: { + variationKey: 'variation_2', + enabled: false, + variables: { size: 'large' }, + ruleKey: 'rule_2', + flagKey: 'flag_2', + userContext: {} as OptimizelyUserContext, + reasons: [], + }, +}; + +/** + * Creates a mock OptimizelyUserContext with all methods stubbed. + * Override specific methods via the overrides parameter. + */ +export function createMockUserContext( + overrides?: Partial>, +): OptimizelyUserContext { + return { + getUserId: vi.fn().mockReturnValue('test-user'), + getAttributes: vi.fn().mockReturnValue({}), + fetchQualifiedSegments: vi.fn().mockResolvedValue(true), + decide: vi.fn().mockReturnValue(MOCK_DECISION), + decideAll: vi.fn().mockReturnValue(MOCK_DECISIONS), + decideForKeys: vi.fn().mockImplementation((keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + if (MOCK_DECISIONS[key]) { + result[key] = MOCK_DECISIONS[key]; + } + } + return result; + }), + setForcedDecision: vi.fn().mockReturnValue(true), + getForcedDecision: vi.fn(), + removeForcedDecision: vi.fn().mockReturnValue(true), + removeAllForcedDecisions: vi.fn().mockReturnValue(true), + trackEvent: vi.fn(), + getOptimizely: vi.fn(), + setQualifiedSegments: vi.fn(), + getQualifiedSegments: vi.fn().mockReturnValue([]), + qualifiedSegments: null, + ...overrides, + } as unknown as OptimizelyUserContext; +} + +/** + * Creates a mock Optimizely Client. + * @param hasConfig - If true, getOptimizelyConfig returns a config object; otherwise null. + */ +export function createMockClient(hasConfig = false): Client { + return { + getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null), + createUserContext: vi.fn(), + onReady: vi.fn().mockResolvedValue({ success: true }), + notificationCenter: {}, + } as unknown as Client; +} + +/** + * Creates a mock client with notification center support and wraps it in OptimizelyProvider. + * Used for integration-style tests that need the full Provider lifecycle. + */ +export function createProviderWrapper(mockUserContext: OptimizelyUserContext) { + let configUpdateCallback: (() => void) | undefined; + + const client = { + getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }), + createUserContext: vi.fn().mockReturnValue(mockUserContext), + onReady: vi.fn().mockResolvedValue(undefined), + isOdpIntegrated: vi.fn().mockReturnValue(false), + notificationCenter: { + addNotificationListener: vi.fn().mockImplementation((type: string, cb: () => void) => { + if (type === 'OPTIMIZELY_CONFIG_UPDATE') { + configUpdateCallback = cb; + } + return 1; + }), + removeNotificationListener: vi.fn(), + }, + } as unknown as Client; + + (client as unknown as Record)[REACT_CLIENT_META] = { + hasOdpManager: false, + hasVuidManager: false, + }; + + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + + return { + wrapper: Wrapper, + client, + fireConfigUpdate: () => configUpdateCallback?.(), + }; +} + +/** + * Creates a lightweight wrapper that provides OptimizelyContext directly + * (bypassing Provider lifecycle). Used for unit tests. + */ +export function createWrapper(store: ProviderStateStore, client: Client) { + const contextValue: OptimizelyContextValue = { store, client }; + + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} diff --git a/src/hooks/useDecide.spec.tsx b/src/hooks/useDecide.spec.tsx index eeb6967..4e36b55 100644 --- a/src/hooks/useDecide.spec.tsx +++ b/src/hooks/useDecide.spec.tsx @@ -15,66 +15,18 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import React from 'react'; -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; -import { OptimizelyContext, ProviderStateStore } from '../provider/index'; +import { ProviderStateStore } from '../provider/index'; import { useDecide } from './useDecide'; -import type { - OptimizelyUserContext, - OptimizelyDecision, - Client, - OptimizelyDecideOption, -} from '@optimizely/optimizely-sdk'; -import type { OptimizelyContextValue } from '../provider/index'; - -const MOCK_DECISION: OptimizelyDecision = { - variationKey: 'variation_1', - enabled: true, - variables: { color: 'red' }, - ruleKey: 'rule_1', - flagKey: 'flag_1', - userContext: {} as OptimizelyUserContext, - reasons: [], -}; - -function createMockUserContext(overrides?: Partial>): OptimizelyUserContext { - return { - getUserId: vi.fn().mockReturnValue('test-user'), - getAttributes: vi.fn().mockReturnValue({}), - fetchQualifiedSegments: vi.fn().mockResolvedValue(true), - decide: vi.fn().mockReturnValue(MOCK_DECISION), - decideAll: vi.fn(), - decideForKeys: vi.fn(), - setForcedDecision: vi.fn().mockReturnValue(true), - getForcedDecision: vi.fn(), - removeForcedDecision: vi.fn().mockReturnValue(true), - removeAllForcedDecisions: vi.fn().mockReturnValue(true), - trackEvent: vi.fn(), - getOptimizely: vi.fn(), - setQualifiedSegments: vi.fn(), - getQualifiedSegments: vi.fn().mockReturnValue([]), - qualifiedSegments: null, - ...overrides, - } as unknown as OptimizelyUserContext; -} - -function createMockClient(hasConfig = false): Client { - return { - getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null), - createUserContext: vi.fn(), - onReady: vi.fn().mockResolvedValue({ success: true }), - notificationCenter: {}, - } as unknown as Client; -} - -function createWrapper(store: ProviderStateStore, client: Client) { - const contextValue: OptimizelyContextValue = { store, client }; - - return function Wrapper({ children }: { children: React.ReactNode }) { - return {children}; - }; -} +import { + MOCK_DECISION, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; describe('useDecide', () => { let store: ProviderStateStore; @@ -177,25 +129,6 @@ describe('useDecide', () => { expect(result.current.decision).toBe(MOCK_DECISION); }); - it('should re-evaluate when setClientReady fire', async () => { - const mockUserContext = createMockUserContext(); - store.setUserContext(mockUserContext); - // Client has no config yet - const wrapper = createWrapper(store, mockClient); - const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); - - expect(result.current.isLoading).toBe(true); - - // Simulate config becoming available when onReady resolves - (mockClient.getOptimizelyConfig as ReturnType).mockReturnValue({ revision: '1' }); - await act(async () => { - store.setClientReady(true); - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.decision).toBe(MOCK_DECISION); - }); - it('should return error from store with isLoading: false', async () => { const wrapper = createWrapper(store, mockClient); const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); @@ -319,31 +252,37 @@ describe('useDecide', () => { expect(result.current.decision).toBeNull(); }); - it('should re-call decide() when setClientReady fires after sync decision was already served', async () => { - // Sync datafile scenario: config + userContext available before onReady - mockClient = createMockClient(true); + it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => { const mockUserContext = createMockUserContext(); - store.setUserContext(mockUserContext); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); - const wrapper = createWrapper(store, mockClient); const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); - // Decision already served - expect(result.current.isLoading).toBe(false); + // Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.decision).toBe(MOCK_DECISION); - expect(mockUserContext.decide).toHaveBeenCalledTimes(1); - // onReady() resolves → setClientReady(true) fires → store state changes → - // useSyncExternalStore re-renders → useMemo recomputes → decide() called again. - // This is a redundant call since config + userContext haven't changed, - // but it's a one-time cost per flag per page load. + const callCountBeforeUpdate = (mockUserContext.decide as ReturnType).mock.calls.length; + + // Simulate a new datafile with a different decision + const updatedDecision: OptimizelyDecision = { + ...MOCK_DECISION, + variationKey: 'variation_2', + variables: { color: 'blue' }, + }; + (mockUserContext.decide as ReturnType).mockReturnValue(updatedDecision); + + // Fire the config update notification (as the SDK would on datafile poll) await act(async () => { - store.setClientReady(true); + fireConfigUpdate(); }); - expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.decision).toBe(updatedDecision); expect(result.current.isLoading).toBe(false); - expect(result.current.decision).toBe(MOCK_DECISION); }); describe('forced decision reactivity', () => { diff --git a/src/hooks/useDecide.ts b/src/hooks/useDecide.ts index 7ab4350..a4453a6 100644 --- a/src/hooks/useDecide.ts +++ b/src/hooks/useDecide.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useEffect, useMemo, useState } from 'react'; import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/optimizely-sdk'; import { useOptimizelyContext } from './useOptimizelyContext'; +import { useProviderState } from './useProviderState'; import { useStableArray } from './useStableArray'; export interface UseDecideConfig { @@ -43,12 +43,7 @@ export type UseDecideResult = export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideResult { const { store, client } = useOptimizelyContext(); const decideOptions = useStableArray(config?.decideOptions); - // --- General state subscription --- - // store.getState() returns a new object on every state change, - // so Object.is comparison works naturally. - const subscribeState = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); - const getStateSnapshot = useCallback(() => store.getState(), [store]); - const state = useSyncExternalStore(subscribeState, getStateSnapshot, getStateSnapshot); + const state = useProviderState(store); // --- Forced decision subscription --- // Forced decisions don't change store state, so we use a version counter diff --git a/src/hooks/useDecideAll.spec.tsx b/src/hooks/useDecideAll.spec.tsx new file mode 100644 index 0000000..bbbe11f --- /dev/null +++ b/src/hooks/useDecideAll.spec.tsx @@ -0,0 +1,322 @@ +/** + * 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 { useDecideAll } from './useDecideAll'; +import { + MOCK_DECISIONS, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; + +describe('useDecideAll', () => { + let store: ProviderStateStore; + let mockClient: Client; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + mockClient = createMockClient(); + }); + + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDecideAll()); + }).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(() => useDecideAll(), { 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(() => useDecideAll(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return isLoading: true when user context is set but no config', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAll(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return empty decisions while loading', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAll(), { wrapper }); + + expect(result.current.decisions).toEqual({}); + expect(result.current.isLoading).toBe(true); + }); + + it('should return decisions when config and user context are available', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAll(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + expect(mockUserContext.decideAll).toHaveBeenCalledWith(undefined); + }); + + it('should pass decideOptions to userContext.decideAll()', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAll({ decideOptions }), { wrapper }); + + expect(mockUserContext.decideAll).toHaveBeenCalledWith(decideOptions); + }); + + it('should return error from store with isLoading: false', async () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAll(), { 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 not call decideAll() while loading', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAll(), { wrapper }); + + expect(mockUserContext.decideAll).not.toHaveBeenCalled(); + }); + + it('should re-evaluate when store state changes (user context set after mount)', async () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideAll(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const mockUserContext = createMockUserContext(); + await act(async () => { + store.setUserContext(mockUserContext); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + }); + + it('should return stable reference when nothing changes', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(() => useDecideAll(), { wrapper }); + + const firstResult = result.current; + rerender(); + + expect(result.current).toBe(firstResult); + }); + + it('should handle decideOptions referential stability via useStableArray', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + + const { result, rerender } = renderHook( + () => + useDecideAll({ + decideOptions: ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[], + }), + { wrapper } + ); + + const firstResult = result.current; + (mockUserContext.decideAll as ReturnType).mockClear(); + + rerender(); + + expect(mockUserContext.decideAll).not.toHaveBeenCalled(); + expect(result.current).toBe(firstResult); + }); + + it('should unsubscribe from store on unmount', () => { + const unsubscribeSpy = vi.fn(); + const subscribeSpy = vi.spyOn(store, 'subscribe').mockReturnValue(unsubscribeSpy); + + const wrapper = createWrapper(store, mockClient); + const { unmount } = renderHook(() => useDecideAll(), { wrapper }); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => { + const mockUserContext = createMockUserContext(); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); + + const { result } = renderHook(() => useDecideAll(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + + const callCountBeforeUpdate = (mockUserContext.decideAll as ReturnType).mock.calls.length; + + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated_variation' }, + }; + (mockUserContext.decideAll as ReturnType).mockReturnValue(updatedDecisions); + + await act(async () => { + fireConfigUpdate(); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.decisions).toEqual(updatedDecisions); + }); + + describe('forced decision reactivity', () => { + it('should re-evaluate on any setForcedDecision via subscribeAllForcedDecisions', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAll(), { wrapper }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(1); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(2); + + // A different flag also triggers re-evaluation + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_99' }, { variationKey: 'v99' }); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(3); + }); + + it('should re-evaluate on removeForcedDecision', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAll(), { wrapper }); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeForcedDecision({ flagKey: 'flag_1' }); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(3); + }); + + it('should re-evaluate on removeAllForcedDecisions', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideAll(), { wrapper }); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideAll).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decideAll).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(() => useDecideAll(), { wrapper }); + + expect(subscribeAllFdSpy).toHaveBeenCalledTimes(1); + + unmount(); + + expect(unsubscribeAllFdSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/hooks/useDecideAll.ts b/src/hooks/useDecideAll.ts new file mode 100644 index 0000000..e472a55 --- /dev/null +++ b/src/hooks/useDecideAll.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 { useEffect, useMemo, useState } from 'react'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useProviderState } from './useProviderState'; +import { useStableArray } from './useStableArray'; +import type { UseDecideConfig } from './useDecide'; +import type { UseDecideMultiResult } from './useDecideForKeys'; + +/** + * Returns feature flag decisions for all flags. + * + * Subscribes to `ProviderStateStore` via `useSyncExternalStore` and + * re-evaluates decisions whenever the store state changes + * (client ready, user context set, error) or any forced decision changes. + * + * @param config - Optional configuration (decideOptions) + */ +export function useDecideAll(config?: UseDecideConfig): UseDecideMultiResult { + 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]); + + // --- Derive decisions --- + return useMemo(() => { + void fdVersion; // referenced to satisfy exhaustive-deps; triggers recomputation on forced decision changes + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + if (error) { + return { decisions: {} as Record, isLoading: false as const, error }; + } + + if (!hasConfig || userContext === null) { + return { decisions: {} as Record, isLoading: true as const, error: null }; + } + + const decisions = userContext.decideAll(decideOptions); + return { decisions, isLoading: false as const, error: null }; + }, [fdVersion, state, client, decideOptions]); +} diff --git a/src/hooks/useDecideForKeys.spec.tsx b/src/hooks/useDecideForKeys.spec.tsx new file mode 100644 index 0000000..faf70ca --- /dev/null +++ b/src/hooks/useDecideForKeys.spec.tsx @@ -0,0 +1,395 @@ +/** + * 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 { useDecideForKeys } from './useDecideForKeys'; +import { + MOCK_DECISIONS, + createMockUserContext, + createMockClient, + createProviderWrapper, + createWrapper, +} from './testUtils'; +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; + +describe('useDecideForKeys', () => { + let store: ProviderStateStore; + let mockClient: Client; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + mockClient = createMockClient(); + }); + + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDecideForKeys(['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(() => useDecideForKeys(['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(() => useDecideForKeys(['flag_1']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return isLoading: true when user context is set but no config', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return empty decisions while loading', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.decisions).toEqual({}); + expect(result.current.isLoading).toBe(true); + }); + + it('should return decisions when config and user context are available', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + expect(mockUserContext.decideForKeys).toHaveBeenCalledWith(['flag_1', 'flag_2'], undefined); + }); + + it('should pass decideOptions to userContext.decideForKeys()', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1'], { decideOptions }), { wrapper }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledWith(['flag_1'], decideOptions); + }); + + it('should return error from store with isLoading: false', async () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeys(['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 not call decideForKeys() while loading', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + expect(mockUserContext.decideForKeys).not.toHaveBeenCalled(); + }); + + it('should re-evaluate when store state changes (user context set after mount)', async () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const mockUserContext = createMockUserContext(); + await act(async () => { + store.setUserContext(mockUserContext); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + }); + + it('should re-evaluate when keys array changes', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ keys }) => useDecideForKeys(keys), { + wrapper, + initialProps: { keys: ['flag_1'] }, + }); + + expect(result.current.decisions).toEqual({ flag_1: MOCK_DECISIONS['flag_1'] }); + + rerender({ keys: ['flag_1', 'flag_2'] }); + + expect(result.current.decisions).toEqual(MOCK_DECISIONS); + expect(mockUserContext.decideForKeys).toHaveBeenCalledWith(['flag_1', 'flag_2'], undefined); + }); + + it('should return stable reference when nothing changes', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + const firstResult = result.current; + rerender(); + + expect(result.current).toBe(firstResult); + }); + + it('should handle decideOptions referential stability via useStableArray', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + + const { result, rerender } = renderHook( + () => + useDecideForKeys(['flag_1'], { + decideOptions: ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[], + }), + { wrapper } + ); + + const firstResult = result.current; + (mockUserContext.decideForKeys as ReturnType).mockClear(); + + rerender(); + + expect(mockUserContext.decideForKeys).not.toHaveBeenCalled(); + expect(result.current).toBe(firstResult); + }); + + it('should handle flagKeys referential stability via useStableArray', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + + // Pass inline array (new reference each render) with same elements + const { result, rerender } = renderHook(({ keys }) => useDecideForKeys(keys), { + wrapper, + initialProps: { keys: ['flag_1', 'flag_2'] }, + }); + + const firstResult = result.current; + (mockUserContext.decideForKeys as ReturnType).mockClear(); + + // Rerender with same values but new array reference + rerender({ keys: ['flag_1', 'flag_2'] }); + + expect(mockUserContext.decideForKeys).not.toHaveBeenCalled(); + expect(result.current).toBe(firstResult); + }); + + it('should unsubscribe from store on unmount', () => { + const unsubscribeSpy = vi.fn(); + const subscribeSpy = vi.spyOn(store, 'subscribe').mockReturnValue(unsubscribeSpy); + + const wrapper = createWrapper(store, mockClient); + const { unmount } = renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => { + const mockUserContext = createMockUserContext(); + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); + + const { result } = renderHook(() => useDecideForKeys(['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.decideForKeys as ReturnType).mock.calls.length; + + const updatedDecisions: Record = { + flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated_variation' }, + }; + (mockUserContext.decideForKeys as ReturnType).mockReturnValue(updatedDecisions); + + await act(async () => { + fireConfigUpdate(); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); + expect(result.current.decisions).toEqual(updatedDecisions); + }); + + describe('forced decision reactivity', () => { + it('should re-evaluate when setForcedDecision is called for a key in the array', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(1); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(2); + }); + + it('should NOT re-evaluate when setForcedDecision is called for a key NOT in the array', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(1); + (mockUserContext.decideForKeys as ReturnType).mockClear(); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_2' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideForKeys).not.toHaveBeenCalled(); + }); + + it('should re-evaluate when removeForcedDecision is called for a key in the array', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeForcedDecision({ flagKey: 'flag_1' }); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(3); + }); + + it('should re-evaluate when removeAllForcedDecisions is called', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecideForKeys(['flag_1']), { wrapper }); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decideForKeys).toHaveBeenCalledTimes(3); + }); + + it('should re-subscribe forced decisions when keys change', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const subscribeFdSpy = vi.spyOn(store, 'subscribeForcedDecision'); + + const wrapper = createWrapper(store, mockClient); + const { rerender } = renderHook(({ keys }) => useDecideForKeys(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', () => { + 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(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper }); + + unmount(); + + // One unsubscribe call per key + expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/hooks/useDecideForKeys.ts b/src/hooks/useDecideForKeys.ts new file mode 100644 index 0000000..b28df9a --- /dev/null +++ b/src/hooks/useDecideForKeys.ts @@ -0,0 +1,71 @@ +/** + * 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, useMemo, 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 UseDecideMultiResult = + | { 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. + * + * Subscribes to `ProviderStateStore` via `useSyncExternalStore` and + * re-evaluates decisions whenever the store state changes + * (client ready, user context set, error) or a forced decision + * changes for any of the watched keys. + * + * @param flagKeys - The feature flag keys to evaluate + * @param config - Optional configuration (decideOptions) + */ +export function useDecideForKeys(flagKeys: string[], config?: UseDecideConfig): UseDecideMultiResult { + 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]); + + // --- Derive decisions --- + return useMemo(() => { + void fdVersion; // referenced to satisfy exhaustive-deps; triggers recomputation on forced decision changes + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + if (error) { + return { decisions: {}, isLoading: false, error }; + } + + if (!hasConfig || userContext === null) { + return { decisions: {}, isLoading: true, error: null }; + } + + const decisions = userContext.decideForKeys(stableKeys, decideOptions); + return { decisions, isLoading: false as const, error: null }; + }, [fdVersion, state, client, stableKeys, decideOptions]); +} diff --git a/src/hooks/useOptimizelyClient.spec.tsx b/src/hooks/useOptimizelyClient.spec.tsx index c65ae36..f1d10d8 100644 --- a/src/hooks/useOptimizelyClient.spec.tsx +++ b/src/hooks/useOptimizelyClient.spec.tsx @@ -81,12 +81,12 @@ describe('useOptimizelyClient', () => { // Trigger store state changes that should NOT cause useOptimizelyClient to re-render act(() => { - store.setClientReady(true); + store.setError(new Error('test')); }); expect(capturedRenderCount).toBe(initialRenderCount); act(() => { - store.setError(new Error('test')); + store.refresh(); }); expect(capturedRenderCount).toBe(initialRenderCount); }); diff --git a/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx index ec35bb2..55acdcd 100644 --- a/src/hooks/useOptimizelyUserContext.spec.tsx +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -206,11 +206,11 @@ describe('useOptimizelyUserContext', () => { const initialRenderCount = capturedRenderCount; - // Changing isClientReady triggers a store notification, + // Triggering a store notification via setState, // but since the derived result hasn't changed, useMemo returns // the same reference and React bails out act(() => { - store.setClientReady(true); + store.refresh(); }); expect(capturedRenderCount).toBe(initialRenderCount); diff --git a/src/hooks/useProviderState.ts b/src/hooks/useProviderState.ts new file mode 100644 index 0000000..03e528e --- /dev/null +++ b/src/hooks/useProviderState.ts @@ -0,0 +1,31 @@ +/** + * 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 } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { ProviderState } from '../provider/index'; +import type { ProviderStateStore } from '../provider/index'; + +/** + * Subscribes to the `ProviderStateStore` via `useSyncExternalStore` + * and returns the current provider state. + */ +export function useProviderState(store: ProviderStateStore): ProviderState { + const subscribeState = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); + const getStateSnapshot = useCallback(() => store.getState(), [store]); + + return useSyncExternalStore(subscribeState, getStateSnapshot, getStateSnapshot); +} diff --git a/src/hooks/useStableArray.ts b/src/hooks/useStableArray.ts index f7648e9..65cf06f 100644 --- a/src/hooks/useStableArray.ts +++ b/src/hooks/useStableArray.ts @@ -21,6 +21,8 @@ import { useRef } from 'react'; * are shallowly equal. Prevents unnecessary re-renders when consumers * pass inline arrays (e.g. `decideOptions: [EXCLUDE_VARIABLES]`). */ +export function useStableArray(arr: T[]): T[]; +export function useStableArray(arr: T[] | undefined): T[] | undefined; export function useStableArray(arr: T[] | undefined): T[] | undefined { const ref = useRef(arr); diff --git a/src/index.ts b/src/index.ts index 5dcae0b..8cbfacb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,14 @@ export { export type * from '@optimizely/optimizely-sdk'; // Provider -// Todo: Remove OptimizelyContext export in future export { OptimizelyProvider } from './provider/index'; export type { UserInfo, OptimizelyProviderProps } from './provider/index'; // Hooks -export { useOptimizelyUserContext, useOptimizelyClient, useDecide } from './hooks/index'; +export { + useOptimizelyUserContext, + useOptimizelyClient, + useDecide, + useDecideForKeys, + useDecideAll, +} from './hooks/index'; diff --git a/src/provider/OptimizelyProvider.spec.tsx b/src/provider/OptimizelyProvider.spec.tsx index b7d5e5d..b7ee639 100644 --- a/src/provider/OptimizelyProvider.spec.tsx +++ b/src/provider/OptimizelyProvider.spec.tsx @@ -55,7 +55,10 @@ function createMockClient( createUserContext: vi.fn().mockReturnValue(mockUserContext), close: vi.fn(), getOptimizelyConfig: vi.fn(), - notificationCenter: {} as OptimizelyClient['notificationCenter'], + notificationCenter: { + addNotificationListener: vi.fn().mockReturnValue(1), + removeNotificationListener: vi.fn(), + } as unknown as OptimizelyClient['notificationCenter'], sendOdpEvent: vi.fn(), isOdpIntegrated: vi.fn().mockReturnValue(false), ...overrides, @@ -151,7 +154,7 @@ describe('OptimizelyProvider', () => { expect(mockClient.onReady).toHaveBeenCalledWith({ timeout: 5000 }); }); - it('should set isClientReady to true when onReady succeeds', async () => { + it('should not set error when onReady succeeds', async () => { const mockClient = createMockClient({ onReady: vi.fn().mockResolvedValue(undefined), }); @@ -165,13 +168,12 @@ describe('OptimizelyProvider', () => { await waitFor(() => { expect(capturedContext).not.toBeNull(); - expect(capturedContext!.store.getState().isClientReady).toBe(true); }); expect(capturedContext!.store.getState().error).toBeNull(); }); - it('should set isClientReady to false and set error when onReady rejects', async () => { + it('should set error when onReady rejects', async () => { const testError = new Error('Client initialization failed'); const mockClient = createMockClient({ onReady: vi.fn().mockRejectedValue(testError), @@ -188,9 +190,6 @@ describe('OptimizelyProvider', () => { expect(capturedContext).not.toBeNull(); expect(capturedContext!.store.getState().error).toBe(testError); }); - - // Client is NOT ready when onReady rejects - expect(capturedContext!.store.getState().isClientReady).toBe(false); }); it('should set error when onReady times out (rejects)', async () => { @@ -210,8 +209,6 @@ describe('OptimizelyProvider', () => { expect(capturedContext).not.toBeNull(); expect(capturedContext!.store.getState().error).toBe(timeoutError); }); - - expect(capturedContext!.store.getState().isClientReady).toBe(false); }); }); @@ -243,7 +240,6 @@ describe('OptimizelyProvider', () => { await waitFor(() => { expect(capturedContext).not.toBeNull(); - expect(capturedContext!.store.getState().isClientReady).toBe(true); }); const store = capturedContext!.store; @@ -251,7 +247,6 @@ describe('OptimizelyProvider', () => { unmount(); // Store should be reset - expect(store.getState().isClientReady).toBe(false); expect(store.getState().userContext).toBeNull(); expect(store.getState().error).toBeNull(); }); @@ -569,8 +564,8 @@ describe('OptimizelyProvider', () => { resolveOnReady!(); }); - // Store was reset on unmount, and onReady resolution should not set isClientReady - expect(store.getState().isClientReady).toBe(false); + // Store was reset on unmount, onReady resolution should not affect store + expect(store.getState().error).toBeNull(); }); it('should call onReady again when client changes', async () => { @@ -638,6 +633,91 @@ describe('OptimizelyProvider', () => { }); }); + describe('config update subscription', () => { + it('should subscribe to OPTIMIZELY_CONFIG_UPDATE on mount', () => { + const mockClient = createMockClient(); + + render( + +
Child
+
+ ); + + expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledWith( + 'OPTIMIZELY_CONFIG_UPDATE', + expect.any(Function) + ); + }); + + it('should remove notification listener on unmount', () => { + const mockClient = createMockClient(); + + const { unmount } = render( + +
Child
+
+ ); + + unmount(); + + expect(mockClient.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1); + }); + + it('should trigger store state change when config update fires', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + }); + + const stateBefore = capturedContext!.store.getState(); + + // Get the callback that was registered and invoke it + const configUpdateCallback = ( + mockClient.notificationCenter.addNotificationListener as ReturnType + ).mock.calls.find((call: unknown[]) => call[0] === 'OPTIMIZELY_CONFIG_UPDATE')![1]; + + await act(() => { + configUpdateCallback(); + }); + + const stateAfter = capturedContext!.store.getState(); + + // State should be a new reference (triggers useSyncExternalStore subscribers) + expect(stateBefore).not.toBe(stateAfter); + }); + + it('should re-subscribe when client changes', () => { + const mockClient1 = createMockClient(); + const mockClient2 = createMockClient(); + + const { rerender } = render( + +
Child
+
+ ); + + expect(mockClient1.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1); + + rerender( + +
Child
+
+ ); + + // Old listener cleaned up, new one registered + expect(mockClient1.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1); + expect(mockClient2.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1); + }); + }); + describe('context reference identity', () => { it('should change context value reference when client changes', async () => { const mockClient1 = createMockClient(); diff --git a/src/provider/OptimizelyProvider.tsx b/src/provider/OptimizelyProvider.tsx index 311fd6a..1f3a6a8 100644 --- a/src/provider/OptimizelyProvider.tsx +++ b/src/provider/OptimizelyProvider.tsx @@ -15,6 +15,7 @@ */ import React, { createContext, useRef, useMemo, useEffect } from 'react'; +import { NOTIFICATION_TYPES } from '@optimizely/optimizely-sdk'; import { ProviderStateStore } from './ProviderStateStore'; import { UserContextManager } from '../utils/UserContextManager'; @@ -76,7 +77,8 @@ export function OptimizelyProvider({ userManagerRef.current.resolveUserContext(user, qualifiedSegments, skipSegments); } - // Effect: Client onReady + // Effect: Client onReady — only needed for error handling. + // Readiness is derived from userContext + getOptimizelyConfig() by hooks. useEffect(() => { if (!client) { logger?.error('OptimizelyProvider must be passed an Optimizely client instance'); @@ -86,29 +88,32 @@ export function OptimizelyProvider({ let isMounted = true; - const waitForClientReady = async (): Promise => { - try { - await client.onReady({ timeout }); + client.onReady({ timeout }).catch((error) => { + if (!isMounted) return; + const err = error instanceof Error ? error : new Error(String(error)); + store.setError(err); + }); - if (!isMounted) return; - - store.setClientReady(true); - } catch (error) { - if (!isMounted) return; - const err = error instanceof Error ? error : new Error(String(error)); - store.setState({ - isClientReady: false, - error: err, - }); - } + return () => { + isMounted = false; }; + }, [client, timeout, store]); - waitForClientReady(); + // Effect: Subscribe to config/datafile updates (e.g., polling) + useEffect(() => { + if (!client) return; + + const listenerId = client.notificationCenter.addNotificationListener( + NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, + () => { + store.refresh(); + } + ); return () => { - isMounted = false; + client.notificationCenter.removeNotificationListener(listenerId); }; - }, [client, timeout, store]); + }, [client, store]); // Cleanup on unmount useEffect(() => { diff --git a/src/provider/ProviderStateStore.spec.ts b/src/provider/ProviderStateStore.spec.ts index b0cf2d2..1397403 100644 --- a/src/provider/ProviderStateStore.spec.ts +++ b/src/provider/ProviderStateStore.spec.ts @@ -47,7 +47,6 @@ describe('ProviderStateStore', () => { it('should have correct initial state', () => { const state = store.getState(); - expect(state.isClientReady).toBe(false); expect(state.userContext).toBeNull(); expect(state.error).toBeNull(); }); @@ -66,13 +65,13 @@ describe('ProviderStateStore', () => { const listener = vi.fn(); store.subscribe(listener); - store.setClientReady(true); + store.setError(new Error('test')); await flushMicrotasks(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - isClientReady: true, + error: expect.any(Error), }) ); }); @@ -83,7 +82,7 @@ describe('ProviderStateStore', () => { store.subscribe(listener1); store.subscribe(listener2); - store.setClientReady(true); + store.setError(new Error('test')); await flushMicrotasks(); expect(listener1).toHaveBeenCalledTimes(1); @@ -95,7 +94,7 @@ describe('ProviderStateStore', () => { const unsubscribe = store.subscribe(listener); unsubscribe(); - store.setClientReady(true); + store.setError(new Error('test')); await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); @@ -108,7 +107,7 @@ describe('ProviderStateStore', () => { unsubscribe(); unsubscribe(); // Second call should not throw - store.setClientReady(true); + store.setError(new Error('test')); await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); }); @@ -120,7 +119,7 @@ describe('ProviderStateStore', () => { unsubscribe1(); const unsubscribe2 = store.subscribe(listener); - store.setClientReady(true); + store.setError(new Error('test')); await flushMicrotasks(); expect(listener).toHaveBeenCalledTimes(1); @@ -129,38 +128,6 @@ describe('ProviderStateStore', () => { }); }); - describe('setClientReady', () => { - it('should update isClientReady state', () => { - store.setClientReady(true); - - expect(store.getState().isClientReady).toBe(true); - }); - - it('should not notify if value has not changed', async () => { - const listener = vi.fn(); - store.subscribe(listener); - - store.setClientReady(false); // Same as initial value - await flushMicrotasks(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('should preserve other state properties', () => { - const mockUserContext = createMockUserContext(); - const mockError = new Error('test'); - - store.setUserContext(mockUserContext); - store.setError(mockError); - store.setClientReady(true); - - const state = store.getState(); - expect(state.userContext).toBe(mockUserContext); - expect(state.error).toBe(mockError); - expect(state.isClientReady).toBe(true); - }); - }); - describe('setUserContext', () => { it('should update userContext state', () => { const mockUserContext = createMockUserContext(); @@ -181,14 +148,12 @@ describe('ProviderStateStore', () => { it('should preserve other state properties', () => { const mockError = new Error('test'); - store.setClientReady(true); store.setError(mockError); const mockUserContext = createMockUserContext(); store.setUserContext(mockUserContext); const state = store.getState(); - expect(state.isClientReady).toBe(true); expect(state.error).toBe(mockError); }); }); @@ -227,13 +192,11 @@ describe('ProviderStateStore', () => { it('should not clear other state when error is set', () => { const mockUserContext = createMockUserContext(); - store.setClientReady(true); store.setUserContext(mockUserContext); store.setError(new Error('test')); const state = store.getState(); - expect(state.isClientReady).toBe(true); expect(state.userContext).toBe(mockUserContext); }); }); @@ -245,7 +208,6 @@ describe('ProviderStateStore', () => { const mockUserContext = createMockUserContext(); store.setState({ - isClientReady: true, userContext: mockUserContext, }); await flushMicrotasks(); @@ -254,7 +216,6 @@ describe('ProviderStateStore', () => { expect(listener).toHaveBeenCalledTimes(1); const state = store.getState(); - expect(state.isClientReady).toBe(true); expect(state.userContext).toBe(mockUserContext); }); @@ -263,28 +224,27 @@ describe('ProviderStateStore', () => { store.subscribe(listener); const mockUserContext = createMockUserContext(); - store.setClientReady(true); store.setUserContext(mockUserContext); store.setError(new Error('test')); await flushMicrotasks(); - // Three state changes, but only one notification due to microtask batching + // Two state changes, but only one notification due to microtask batching expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - isClientReady: true, userContext: mockUserContext, }) ); }); it('should allow partial updates', () => { - store.setClientReady(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); store.setState({ error: new Error('test') }); const state = store.getState(); - expect(state.isClientReady).toBe(true); + expect(state.userContext).toBe(mockUserContext); expect(state.error).not.toBeNull(); }); }); @@ -292,21 +252,19 @@ describe('ProviderStateStore', () => { describe('reset', () => { it('should reset to initial state', () => { const mockUserContext = createMockUserContext(); - store.setClientReady(true); store.setUserContext(mockUserContext); store.setError(new Error('test')); store.reset(); const state = store.getState(); - expect(state.isClientReady).toBe(false); expect(state.userContext).toBeNull(); expect(state.error).toBeNull(); }); it('should notify listeners on reset', async () => { const listener = vi.fn(); - store.setClientReady(true); + store.setUserContext(createMockUserContext()); store.subscribe(listener); store.reset(); @@ -524,6 +482,76 @@ describe('ProviderStateStore', () => { expect(listener).not.toHaveBeenCalled(); }); + it('subscribeAllForcedDecisions callback fires on any notifyForcedDecision', () => { + const ctx = createMockUserContext(); + const allListener = vi.fn(); + + store.subscribeAllForcedDecisions(allListener); + store.setUserContext(ctx); + + ctx.setForcedDecision({ flagKey: 'flag-a' }, { variationKey: 'v1' }); + + expect(allListener).toHaveBeenCalledTimes(1); + + ctx.setForcedDecision({ flagKey: 'flag-b' }, { variationKey: 'v2' }); + + expect(allListener).toHaveBeenCalledTimes(2); + }); + + it('subscribeAllForcedDecisions unsubscribe stops notifications', () => { + const ctx = createMockUserContext(); + const allListener = vi.fn(); + + const unsubscribe = store.subscribeAllForcedDecisions(allListener); + store.setUserContext(ctx); + + ctx.setForcedDecision({ flagKey: 'flag-a' }, { variationKey: 'v1' }); + expect(allListener).toHaveBeenCalledTimes(1); + + unsubscribe(); + allListener.mockClear(); + + ctx.setForcedDecision({ flagKey: 'flag-b' }, { variationKey: 'v2' }); + expect(allListener).not.toHaveBeenCalled(); + }); + + it('subscribeAllForcedDecisions fires on removeAllForcedDecisions', () => { + const ctx = createMockUserContext(); + const allListener = vi.fn(); + + store.subscribeAllForcedDecisions(allListener); + store.setUserContext(ctx); + + ctx.setForcedDecision({ flagKey: 'flag-a' }, { variationKey: 'v1' }); + ctx.setForcedDecision({ flagKey: 'flag-b' }, { variationKey: 'v2' }); + allListener.mockClear(); + + ctx.removeAllForcedDecisions(); + + // removeAll broadcasts once (not once per key) + expect(allListener).toHaveBeenCalledTimes(1); + }); + + it('reset clears subscribeAllForcedDecisions listeners', () => { + const ctx = createMockUserContext(); + const allListener = vi.fn(); + + store.subscribeAllForcedDecisions(allListener); + store.setUserContext(ctx); + + ctx.setForcedDecision({ flagKey: 'flag-a' }, { variationKey: 'v1' }); + expect(allListener).toHaveBeenCalledTimes(1); + allListener.mockClear(); + + store.reset(); + + const ctxNew = createMockUserContext(); + store.setUserContext(ctxNew); + + ctxNew.setForcedDecision({ flagKey: 'flag-a' }, { variationKey: 'v2' }); + expect(allListener).not.toHaveBeenCalled(); + }); + it('original methods are still called on the underlying context', () => { const originalSet = vi.fn().mockReturnValue(true); const originalRemove = vi.fn().mockReturnValue(true); diff --git a/src/provider/ProviderStateStore.ts b/src/provider/ProviderStateStore.ts index 8840d79..8a265f0 100644 --- a/src/provider/ProviderStateStore.ts +++ b/src/provider/ProviderStateStore.ts @@ -31,7 +31,6 @@ export type ForcedDecisionListener = () => void; * Initial state for the provider store. */ const initialState: ProviderState = { - isClientReady: false, userContext: null, error: null, }; @@ -48,12 +47,14 @@ export class ProviderStateStore { private state: ProviderState; private listeners: Set; private forcedDecisionListeners: Map>; + private allForcedDecisionListeners: Set; private notifyScheduled = false; constructor() { this.state = { ...initialState }; this.listeners = new Set(); this.forcedDecisionListeners = new Map(); + this.allForcedDecisionListeners = new Set(); } /** @@ -80,19 +81,6 @@ export class ProviderStateStore { }; } - /** - * Set whether the client is ready. - * e.g: Called by Provider after client.onReady() resolves. - */ - setClientReady(ready: boolean): void { - if (this.state.isClientReady === ready) { - return; - } - - this.state = { ...this.state, isClientReady: ready }; - this.notifyListeners(); - } - /** * Set the current user context. * e.g: Called by UserContextManager when user context is created. @@ -114,7 +102,7 @@ export class ProviderStateStore { /** * Set an error that occurred during initialization. - * Setting an error does NOT clear userContext or isClientReady. + * Setting an error does NOT clear userContext. */ setError(error: Error | null): void { if (this.state.error === error) { @@ -137,6 +125,16 @@ export class ProviderStateStore { this.notifyListeners(); } + /** + * Signal that external state (e.g. client config) has changed. + * Creates a new state reference so useSyncExternalStore triggers + * re-renders and hooks re-evaluate decisions. + */ + refresh(): void { + this.state = { ...this.state }; + this.notifyListeners(); + } + /** * Reset store to initial state. * Useful for testing or when Provider unmounts. @@ -144,6 +142,7 @@ export class ProviderStateStore { reset(): void { this.state = { ...initialState }; this.forcedDecisionListeners.clear(); + this.allForcedDecisionListeners.clear(); this.notifyListeners(); } @@ -171,16 +170,48 @@ export class ProviderStateStore { } /** - * Notify listeners subscribed to a specific flagKey. - * Called internally by wrapped forced decision methods. + * Subscribe to forced decision changes for all flagKeys. + * Used by hooks like useDecideAll that need to react to any forced decision change. + * + * @param callback - Called when any forced decision changes + * @returns Unsubscribe function + */ + subscribeAllForcedDecisions(callback: ForcedDecisionListener): () => void { + this.allForcedDecisionListeners.add(callback); + return () => { + this.allForcedDecisionListeners.delete(callback); + }; + } + + /** + * Notify listeners subscribed to a specific flagKey + * and broadcast to "all" forced decision listeners. + * Called by wrapped setForcedDecision / removeForcedDecision. */ notifyForcedDecision(flagKey: string): void { + this.notifyPerKeyForcedDecision(flagKey); + this.notifyAllForcedDecisionListeners(); + } + + /** + * Notify only per-key listeners (no broadcast). + * Used internally by removeAllForcedDecisions to avoid + * firing "all" listeners once per key. + */ + private notifyPerKeyForcedDecision(flagKey: string): void { const listeners = this.forcedDecisionListeners.get(flagKey); if (listeners) { listeners.forEach((cb) => cb()); } } + /** + * Notify broadcast ("all") forced decision listeners once. + */ + private notifyAllForcedDecisionListeners(): void { + this.allForcedDecisionListeners.forEach((cb) => cb()); + } + /** * Wrap forced decision methods on a user context to trigger per-flagKey * notifications on mutation. This enables React hooks to re-evaluate @@ -225,7 +256,9 @@ export class ProviderStateStore { const result = originalRemoveAll(); if (result) { if (this.state.userContext === ctx) { - forcedDecisionFlagKeys.forEach((flagKey) => this.notifyForcedDecision(flagKey)); + // Notify per-key listeners individually, then broadcast once + forcedDecisionFlagKeys.forEach((flagKey) => this.notifyPerKeyForcedDecision(flagKey)); + this.notifyAllForcedDecisionListeners(); } forcedDecisionFlagKeys.clear(); } diff --git a/src/provider/types.ts b/src/provider/types.ts index a02f5ad..c7b5c28 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -71,11 +71,6 @@ export interface OptimizelyProviderProps { * This is the reactive state that hooks subscribe to. */ export interface ProviderState { - /** - * Whether js onReady() is resolved. - */ - isClientReady: boolean; - /** * The current user context for making decisions. * null while initializing or if user creation failed.