diff --git a/package-lock.json b/package-lock.json index 5704c48..994e298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@optimizely/optimizely-sdk": "^6.3.0", - "hoist-non-react-statics": "^3.3.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "use-sync-external-store": "^1.6.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", @@ -22,6 +22,7 @@ "@types/jest": "^30.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@types/use-sync-external-store": "^1.5.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -2816,6 +2817,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -6132,6 +6140,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -7844,7 +7853,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8493,7 +8501,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -9464,7 +9471,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -9491,6 +9497,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/redent": { @@ -11058,6 +11065,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/package.json b/package.json index a8af4e8..662f2c5 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ }, "dependencies": { "@optimizely/optimizely-sdk": "^6.3.0", - "hoist-non-react-statics": "^3.3.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" @@ -60,6 +60,7 @@ "@types/jest": "^30.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@types/use-sync-external-store": "^1.5.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^4.0.18", diff --git a/rollup.config.mjs b/rollup.config.mjs index 825cc90..b4d6cd5 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -19,7 +19,8 @@ import terser from '@rollup/plugin-terser'; import pkg from './package.json' with { type: 'json' }; const { dependencies, peerDependencies } = pkg; -const external = [...Object.keys(dependencies || {}), ...Object.keys(peerDependencies || {}), 'crypto']; +const externalDeps = [...Object.keys(dependencies || {}), ...Object.keys(peerDependencies || {}), 'crypto']; +const external = (id) => externalDeps.some((dep) => id === dep || id.startsWith(dep + '/')); export default { input: '.build/index.js', diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..2ef9b8b --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export { useOptimizelyClient } from './useOptimizelyClient'; +export { useOptimizelyUserContext } from './useOptimizelyUserContext'; +export type { UseOptimizelyUserContextResult } from './useOptimizelyUserContext'; +export { useDecide } from './useDecide'; +export type { UseDecideConfig, UseDecideResult } from './useDecide'; diff --git a/src/hooks/useDecide.spec.tsx b/src/hooks/useDecide.spec.tsx new file mode 100644 index 0000000..eeb6967 --- /dev/null +++ b/src/hooks/useDecide.spec.tsx @@ -0,0 +1,476 @@ +/** + * 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 React from 'react'; +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { OptimizelyContext, 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}; + }; +} + +describe('useDecide', () => { + 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(() => useDecide('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(() => useDecide('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(() => useDecide('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(() => useDecide('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return null decision while loading', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('my_flag'), { wrapper }); + + expect(result.current.decision).toBeNull(); + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('should return actual decision when config and user context are available', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.decision).toBe(MOCK_DECISION); + expect(mockUserContext.decide).toHaveBeenCalledWith('flag_1', undefined); + }); + + it('should pass decideOptions to userContext.decide()', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const decideOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[]; + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecide('flag_1', { decideOptions }), { wrapper }); + + expect(mockUserContext.decide).toHaveBeenCalledWith('flag_1', decideOptions); + }); + + 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(() => useDecide('flag_1'), { 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.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 }); + + 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 re-evaluate when flagKey changes', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + + const decisionForFlag2: OptimizelyDecision = { + ...MOCK_DECISION, + flagKey: 'flag_2', + variationKey: 'variation_2', + }; + (mockUserContext.decide as ReturnType).mockImplementation((key: string) => { + return key === 'flag_2' ? decisionForFlag2 : MOCK_DECISION; + }); + + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), { + wrapper, + initialProps: { flagKey: 'flag_1' }, + }); + + expect(result.current.decision).toBe(MOCK_DECISION); + + rerender({ flagKey: 'flag_2' }); + + expect(result.current.decision).toBe(decisionForFlag2); + expect(mockUserContext.decide).toHaveBeenCalledWith('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(() => useDecide('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); + + // Pass inline array (new reference each render) with same elements + const { result, rerender } = renderHook( + () => useDecide('flag_1', { decideOptions: ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[] }), + { wrapper } + ); + + const firstResult = result.current; + (mockUserContext.decide as ReturnType).mockClear(); + + rerender(); + + // Should NOT re-call decide() since the array elements are the same + expect(mockUserContext.decide).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(() => useDecide('flag_1'), { wrapper }); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('should not call decide() while loading', () => { + const mockUserContext = createMockUserContext(); + // Config not available, user context set + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecide('flag_1'), { wrapper }); + + // decide should not be called because config is not available + expect(mockUserContext.decide).not.toHaveBeenCalled(); + }); + + it('should return null decision for both flagKeys when loading', () => { + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), { + wrapper, + initialProps: { flagKey: 'flag_a' }, + }); + + expect(result.current.decision).toBeNull(); + + rerender({ flagKey: 'flag_b' }); + + 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); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); + + // Decision already served + 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. + await act(async () => { + store.setClientReady(true); + }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.decision).toBe(MOCK_DECISION); + }); + + describe('forced decision reactivity', () => { + it('should re-evaluate when setForcedDecision is called for the same flagKey', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(1); + + const forcedDecision: OptimizelyDecision = { + ...MOCK_DECISION, + variationKey: 'forced_variation', + }; + (mockUserContext.decide as ReturnType).mockReturnValue(forcedDecision); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' }); + }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + expect(result.current.decision).toBe(forcedDecision); + }); + + it('should NOT re-evaluate when setForcedDecision is called for a different flagKey', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecide('flag_1'), { wrapper }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(1); + (mockUserContext.decide as ReturnType).mockClear(); + + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_2' }, { variationKey: 'v1' }); + }); + + // flag_1 hook should NOT re-evaluate — different flagKey + expect(mockUserContext.decide).not.toHaveBeenCalled(); + }); + + it('should re-evaluate when removeForcedDecision is called for the same flagKey', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store, mockClient); + renderHook(() => useDecide('flag_1'), { wrapper }); + + // Set then remove + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeForcedDecision({ flagKey: 'flag_1' }); + }); + + expect(mockUserContext.decide).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(() => useDecide('flag_1'), { wrapper }); + + // Set a forced decision to register the flagKey internally + act(() => { + mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' }); + }); + // (mockUserContext.decide as ReturnType).mockClear(); + expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + + act(() => { + mockUserContext.removeAllForcedDecisions(); + }); + + expect(mockUserContext.decide).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(() => useDecide('flag_1'), { wrapper }); + + expect(subscribeFdSpy).toHaveBeenCalledTimes(1); + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_1', expect.any(Function)); + + unmount(); + + expect(unsubscribeFdSpy).toHaveBeenCalledTimes(1); + }); + + it('should re-subscribe to forced decisions when flagKey changes', () => { + mockClient = createMockClient(true); + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const subscribeFdSpy = vi.spyOn(store, 'subscribeForcedDecision'); + + const wrapper = createWrapper(store, mockClient); + const { rerender } = renderHook(({ flagKey }) => useDecide(flagKey), { + wrapper, + initialProps: { flagKey: 'flag_1' }, + }); + + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_1', expect.any(Function)); + + rerender({ flagKey: 'flag_2' }); + + expect(subscribeFdSpy).toHaveBeenCalledWith('flag_2', expect.any(Function)); + }); + }); +}); diff --git a/src/hooks/useDecide.ts b/src/hooks/useDecide.ts new file mode 100644 index 0000000..7ab4350 --- /dev/null +++ b/src/hooks/useDecide.ts @@ -0,0 +1,81 @@ +/** + * 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, useMemo, useState } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useStableArray } from './useStableArray'; + +export interface UseDecideConfig { + decideOptions?: OptimizelyDecideOption[]; +} + +export type UseDecideResult = + | { 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. + * + * Subscribes to `ProviderStateStore` via `useSyncExternalStore` and + * re-evaluates the decision whenever the store state changes + * (client ready, user context set, error). + * + * @param flagKey - The feature flag key to evaluate + * @param config - Optional configuration (decideOptions) + */ +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); + + // --- Forced decision subscription --- + // Forced decisions don't change store state, so we use a version counter + // to trigger useMemo recomputation. Per-flagKey granularity prevents + // unrelated hooks from re-evaluating. + const [fdVersion, setFdVersion] = useState(0); + useEffect(() => { + return store.subscribeForcedDecision(flagKey, () => { + setFdVersion((v) => v + 1); + }); + }, [store, flagKey]); + + // --- Derive decision --- + 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 { decision: null, isLoading: false, error }; + } + + if (!hasConfig || userContext === null) { + return { decision: null, isLoading: true, error: null }; + } + + const decision = userContext.decide(flagKey, decideOptions); + return { decision, isLoading: false, error: null }; + }, [fdVersion, state, client, flagKey, decideOptions]); +} diff --git a/src/hooks/useOptimizelyClient.spec.tsx b/src/hooks/useOptimizelyClient.spec.tsx new file mode 100644 index 0000000..c65ae36 --- /dev/null +++ b/src/hooks/useOptimizelyClient.spec.tsx @@ -0,0 +1,93 @@ +/** + * 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 } from 'vitest'; +import React, { useRef } from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { useOptimizelyClient } from './useOptimizelyClient'; +import { OptimizelyProvider, ProviderStateStore, OptimizelyContext } from '../provider/index'; +import { createInstance, createStaticProjectConfigManager } from '../client/index'; +import type { OptimizelyContextValue } from '../provider/index'; + +function createClient() { + return createInstance({ + projectConfigManager: createStaticProjectConfigManager({ datafile: JSON.stringify({}) }), + }); +} + +function useRenderCount() { + const renderCount = useRef(0); + return ++renderCount.current; +} + +describe('useOptimizelyClient', () => { + it('should throw when used outside of OptimizelyProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useOptimizelyClient()); + }).toThrow('Optimizely hooks must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return the same client instance passed to OptimizelyProvider', () => { + const client = createClient(); + + const { result } = renderHook(() => useOptimizelyClient(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(client); + }); + + it('should not re-render when store state changes', () => { + const client = createClient(); + const store = new ProviderStateStore(); + + const contextValue: OptimizelyContextValue = { store, client }; + + let capturedRenderCount = 0; + + function TestComponent() { + const hookClient = useOptimizelyClient(); + const renderCount = useRenderCount(); + capturedRenderCount = renderCount; + return
{hookClient ? 'has-client' : 'no-client'}
; + } + + render( + + + + ); + + expect(screen.getByTestId('client').textContent).toBe('has-client'); + const initialRenderCount = capturedRenderCount; + + // Trigger store state changes that should NOT cause useOptimizelyClient to re-render + act(() => { + store.setClientReady(true); + }); + expect(capturedRenderCount).toBe(initialRenderCount); + + act(() => { + store.setError(new Error('test')); + }); + expect(capturedRenderCount).toBe(initialRenderCount); + }); +}); diff --git a/src/hooks/useOptimizelyClient.ts b/src/hooks/useOptimizelyClient.ts new file mode 100644 index 0000000..b81d50e --- /dev/null +++ b/src/hooks/useOptimizelyClient.ts @@ -0,0 +1,25 @@ +/** + * 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 type { Client } from '@optimizely/optimizely-sdk'; +import { useOptimizelyContext } from './useOptimizelyContext'; + +/** + * Returns the Optimizely client instance from the nearest ``. + */ +export function useOptimizelyClient(): Client { + return useOptimizelyContext().client; +} diff --git a/src/hooks/useOptimizelyContext.ts b/src/hooks/useOptimizelyContext.ts new file mode 100644 index 0000000..c77ce7e --- /dev/null +++ b/src/hooks/useOptimizelyContext.ts @@ -0,0 +1,36 @@ +/** + * 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 { useContext } from 'react'; +import { OptimizelyContext } from '../provider/index'; +import type { OptimizelyContextValue } from '../provider/index'; + +/** + * Returns the Optimizely context value from the nearest ``. + * Throws if used outside of a Provider. + * + * Internal hook — shared by all public hooks to avoid duplicating + * the context access + validation pattern. + */ +export function useOptimizelyContext(): OptimizelyContextValue { + const context = useContext(OptimizelyContext); + + if (!context) { + throw new Error('Optimizely hooks must be used within an '); + } + + return context; +} diff --git a/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx new file mode 100644 index 0000000..ec35bb2 --- /dev/null +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -0,0 +1,219 @@ +/** + * 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 React, { useRef } from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { OptimizelyContext } from '../provider/OptimizelyProvider'; +import { ProviderStateStore } from '../provider/ProviderStateStore'; +import { useOptimizelyUserContext } from './useOptimizelyUserContext'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; +import type { OptimizelyContextValue } from '../provider/types'; + +function useRenderCount() { + const renderCount = useRef(0); + return ++renderCount.current; +} + +function createMockUserContext(userId = 'test-user'): OptimizelyUserContext { + return { + getUserId: vi.fn().mockReturnValue(userId), + getAttributes: vi.fn().mockReturnValue({}), + fetchQualifiedSegments: vi.fn().mockResolvedValue(true), + decide: vi.fn(), + decideAll: vi.fn(), + decideForKeys: vi.fn(), + setForcedDecision: vi.fn(), + getForcedDecision: vi.fn(), + removeForcedDecision: vi.fn(), + removeAllForcedDecisions: vi.fn(), + trackEvent: vi.fn(), + getOptimizely: vi.fn(), + setQualifiedSegments: vi.fn(), + getQualifiedSegments: vi.fn().mockReturnValue([]), + qualifiedSegments: null, + } as unknown as OptimizelyUserContext; +} + +function createWrapper(store: ProviderStateStore) { + const contextValue: OptimizelyContextValue = { + store, + client: {} as OptimizelyContextValue['client'], + }; + + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe('useOptimizelyUserContext', () => { + let store: ProviderStateStore; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ProviderStateStore(); + }); + + it('should throw when used outside of OptimizelyProvider', () => { + // Suppress React error boundary console output + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useOptimizelyUserContext()); + }).toThrow('Optimizely hooks must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return isLoading: true with null userContext when no user context is set', () => { + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.userContext).toBeNull(); + }); + + it('should return the current user context with isLoading: false', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.userContext).toBe(mockUserContext); + }); + + it('should update when user context changes', async () => { + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.userContext).toBeNull(); + + const mockUserContext = createMockUserContext('user-1'); + await act(async () => { + store.setUserContext(mockUserContext); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.userContext).toBe(mockUserContext); + }); + + it('should update when user context changes to a different user', async () => { + const userContext1 = createMockUserContext('user-1'); + store.setUserContext(userContext1); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current.userContext).toBe(userContext1); + + const userContext2 = createMockUserContext('user-2'); + await act(async () => { + store.setUserContext(userContext2); + }); + + expect(result.current.userContext).toBe(userContext2); + }); + + it('should return isLoading: true when store is reset', async () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current.userContext).toBe(mockUserContext); + + await act(async () => { + store.reset(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.userContext).toBeNull(); + }); + + it('should return error with isLoading: false when store has error', async () => { + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { 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.userContext).toBeNull(); + }); + + it('should unsubscribe from store on unmount', () => { + const unsubscribeSpy = vi.fn(); + const subscribeSpy = vi.spyOn(store, 'subscribe').mockReturnValue(unsubscribeSpy); + + const wrapper = createWrapper(store); + const { unmount } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('should not re-render when unrelated state changes occur', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + let capturedRenderCount = 0; + function TestComponent() { + const { userContext } = useOptimizelyUserContext(); + const renderCount = useRenderCount(); + capturedRenderCount = renderCount; + return
{userContext?.getUserId()}
; + } + + const contextValue: OptimizelyContextValue = { + store, + client: {} as OptimizelyContextValue['client'], + }; + + render( + + + + ); + + const initialRenderCount = capturedRenderCount; + + // Changing isClientReady triggers a store notification, + // but since the derived result hasn't changed, useMemo returns + // the same reference and React bails out + act(() => { + store.setClientReady(true); + }); + + expect(capturedRenderCount).toBe(initialRenderCount); + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + }); +}); diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts new file mode 100644 index 0000000..a03a32c --- /dev/null +++ b/src/hooks/useOptimizelyUserContext.ts @@ -0,0 +1,55 @@ +/** + * 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, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; + +export type UseOptimizelyUserContextResult = + | { isLoading: true; error: null; userContext: null } + | { isLoading: false; error: Error; userContext: null } + | { isLoading: false; error: null; userContext: OptimizelyUserContext }; + +/** + * Returns the current {@link OptimizelyUserContext} for the nearest ``. + * + * The user context gives access to the user's identity (user ID and attributes) + * and methods for working with forced decisions (`setForcedDecision`, + * `removeForcedDecision`, `removeAllForcedDecisions`). + */ +export function useOptimizelyUserContext(): UseOptimizelyUserContextResult { + const { store } = useOptimizelyContext(); + + const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); + const getSnapshot = useCallback(() => store.getState(), [store]); + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return useMemo(() => { + const { userContext, error } = state; + + if (error) { + return { userContext: null, isLoading: false, error }; + } + + if (userContext === null) { + return { userContext: null, isLoading: true, error: null }; + } + + return { userContext, isLoading: false, error: null }; + }, [state]); +} diff --git a/src/hooks/useStableArray.ts b/src/hooks/useStableArray.ts new file mode 100644 index 0000000..f7648e9 --- /dev/null +++ b/src/hooks/useStableArray.ts @@ -0,0 +1,47 @@ +/** + * 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 { useRef } from 'react'; + +/** + * Returns a referentially stable array reference as long as the elements + * are shallowly equal. Prevents unnecessary re-renders when consumers + * pass inline arrays (e.g. `decideOptions: [EXCLUDE_VARIABLES]`). + */ +export function useStableArray(arr: T[] | undefined): T[] | undefined { + const ref = useRef(arr); + + if (!shallowEqualArrays(ref.current, arr)) { + ref.current = arr; + } + + return ref.current; +} + +function shallowEqualArrays(a: T[] | undefined, b: T[] | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + if (a.length !== b.length) return false; + + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + + for (let i = 0; i < sortedA.length; i++) { + if (sortedA[i] !== sortedB[i]) return false; + } + + return true; +} diff --git a/src/index.ts b/src/index.ts index 63e9645..5dcae0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,12 +23,15 @@ export { createOdpManager, createVuidManager, createErrorNotifier, - createLogger + createLogger, } from './client/index'; -export type * from "@optimizely/optimizely-sdk"; +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'; diff --git a/src/provider/ProviderStateStore.spec.ts b/src/provider/ProviderStateStore.spec.ts index 73c7bd2..b0cf2d2 100644 --- a/src/provider/ProviderStateStore.spec.ts +++ b/src/provider/ProviderStateStore.spec.ts @@ -17,6 +17,8 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { ProviderStateStore } from './ProviderStateStore'; +const flushMicrotasks = () => new Promise((resolve) => queueMicrotask(resolve)); + describe('ProviderStateStore', () => { let store: ProviderStateStore; @@ -60,11 +62,12 @@ describe('ProviderStateStore', () => { expect(typeof unsubscribe).toBe('function'); }); - it('should notify listener when state changes', () => { + it('should notify listener when state changes', async () => { const listener = vi.fn(); store.subscribe(listener); store.setClientReady(true); + await flushMicrotasks(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith( @@ -74,29 +77,31 @@ describe('ProviderStateStore', () => { ); }); - it('should notify multiple listeners', () => { + it('should notify multiple listeners', async () => { const listener1 = vi.fn(); const listener2 = vi.fn(); store.subscribe(listener1); store.subscribe(listener2); store.setClientReady(true); + await flushMicrotasks(); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); }); - it('should not notify after unsubscribe', () => { + it('should not notify after unsubscribe', async () => { const listener = vi.fn(); const unsubscribe = store.subscribe(listener); unsubscribe(); store.setClientReady(true); + await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); }); - it('should handle multiple unsubscribes gracefully', () => { + it('should handle multiple unsubscribes gracefully', async () => { const listener = vi.fn(); const unsubscribe = store.subscribe(listener); @@ -104,10 +109,11 @@ describe('ProviderStateStore', () => { unsubscribe(); // Second call should not throw store.setClientReady(true); + await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); }); - it('should allow re-subscribing after unsubscribe', () => { + it('should allow re-subscribing after unsubscribe', async () => { const listener = vi.fn(); const unsubscribe1 = store.subscribe(listener); @@ -115,6 +121,7 @@ describe('ProviderStateStore', () => { const unsubscribe2 = store.subscribe(listener); store.setClientReady(true); + await flushMicrotasks(); expect(listener).toHaveBeenCalledTimes(1); @@ -129,11 +136,12 @@ describe('ProviderStateStore', () => { expect(store.getState().isClientReady).toBe(true); }); - it('should not notify if value has not changed', () => { + 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(); }); @@ -203,14 +211,16 @@ describe('ProviderStateStore', () => { expect(store.getState().error).toBeNull(); }); - it('should not notify if same error reference', () => { + it('should not notify if same error reference', async () => { const error = new Error('Test error'); store.setError(error); + await flushMicrotasks(); // flush notification from initial setError const listener = vi.fn(); store.subscribe(listener); store.setError(error); + await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); }); @@ -229,7 +239,7 @@ describe('ProviderStateStore', () => { }); describe('setState', () => { - it('should batch update multiple properties', () => { + it('should batch update multiple properties', async () => { const listener = vi.fn(); store.subscribe(listener); @@ -238,6 +248,7 @@ describe('ProviderStateStore', () => { isClientReady: true, userContext: mockUserContext, }); + await flushMicrotasks(); // Should only notify once for batch update expect(listener).toHaveBeenCalledTimes(1); @@ -247,6 +258,26 @@ describe('ProviderStateStore', () => { expect(state.userContext).toBe(mockUserContext); }); + it('should batch multiple synchronous updates into one notification', async () => { + const listener = vi.fn(); + 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 + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + isClientReady: true, + userContext: mockUserContext, + }) + ); + }); + it('should allow partial updates', () => { store.setClientReady(true); @@ -273,12 +304,13 @@ describe('ProviderStateStore', () => { expect(state.error).toBeNull(); }); - it('should notify listeners on reset', () => { + it('should notify listeners on reset', async () => { const listener = vi.fn(); store.setClientReady(true); store.subscribe(listener); store.reset(); + await flushMicrotasks(); expect(listener).toHaveBeenCalledTimes(1); }); diff --git a/src/provider/ProviderStateStore.ts b/src/provider/ProviderStateStore.ts index f620cb2..8840d79 100644 --- a/src/provider/ProviderStateStore.ts +++ b/src/provider/ProviderStateStore.ts @@ -48,6 +48,7 @@ export class ProviderStateStore { private state: ProviderState; private listeners: Set; private forcedDecisionListeners: Map>; + private notifyScheduled = false; constructor() { this.state = { ...initialState }; @@ -234,10 +235,24 @@ export class ProviderStateStore { /** * Notify all listeners of state change. + * + * Notifications are deferred via queueMicrotask to avoid triggering + * setState in subscriber hooks (e.g. useDecide -> useSyncExternalStore) + * while the Provider component is still rendering. + * + * The state itself is updated synchronously so getState() returns the correct value + * immediately (required for SSR). + * + * Multiple synchronous state changes are batched into a single notification. */ private notifyListeners(): void { - this.listeners.forEach((listener) => { - listener(this.state); + if (this.notifyScheduled) return; + this.notifyScheduled = true; + queueMicrotask(() => { + this.notifyScheduled = false; + this.listeners.forEach((listener) => { + listener(this.state); + }); }); } } diff --git a/src/utils/UserContextManager.ts b/src/utils/UserContextManager.ts index 2afd426..cc1c1e3 100644 --- a/src/utils/UserContextManager.ts +++ b/src/utils/UserContextManager.ts @@ -104,7 +104,7 @@ export class UserContextManager { const ctx = this.client.createUserContext(user?.id, user?.attributes); - if (qualifiedSegments) { + if (qualifiedSegments !== undefined) { ctx.qualifiedSegments = qualifiedSegments; this.onUserContextReady(ctx); // immediate callback for sync decision with pre-set segments diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 7d43df2..6414b3e 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -21,7 +21,7 @@ import type { UserInfo } from '../provider/types'; * Used to prevent redundant user context creation when the segments prop * is referentially different but value-equal. */ -export function areSegmentsEqual(a?: string[] | null, b?: string[] | null): boolean { +export function areSegmentsEqual(a?: string[], b?: string[]): boolean { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; diff --git a/src/utils/index.ts b/src/utils/index.ts index 7b86f6b..65ef03e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -17,4 +17,4 @@ export { UserContextManager } from './UserContextManager'; export type { UserContextManagerConfig } from './UserContextManager'; -export { areUsersEqual } from './helpers'; +export * from './helpers'; diff --git a/vitest.config.mts b/vitest.config.mts index c725f16..dd653e6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -11,13 +11,14 @@ export default defineConfig({ 'src/client/**/*.spec.{ts,tsx}', 'src/provider/**/*.spec.{ts,tsx}', 'src/utils/**/*.spec.{ts,tsx}', + 'src/hooks/**/*.spec.{ts,tsx}', // Add more paths as migration progresses ], coverage: { provider: 'v8', reporter: ['text-summary', 'lcov'], reportsDirectory: './coverage', - include: ['src/client/**', 'src/provider/**', 'src/utils/**'], + include: ['src/client/**', 'src/provider/**', 'src/utils/**', 'src/hooks/**'], }, }, });