From 3286e80e0451e59fe7595ce52e75af9cbe017c61 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:32:47 +0600 Subject: [PATCH 01/10] [fssdk-12295] update --- src/hooks/index.ts | 17 ++ src/hooks/useOptimizelyUserContext.spec.tsx | 209 ++++++++++++++++++++ src/hooks/useOptimizelyUserContext.ts | 59 ++++++ src/index.ts | 3 + vitest.config.mts | 3 +- 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useOptimizelyUserContext.spec.tsx create mode 100644 src/hooks/useOptimizelyUserContext.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..64f691d --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,17 @@ +/** + * 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 { useOptimizelyUserContext } from './useOptimizelyUserContext'; diff --git a/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx new file mode 100644 index 0000000..2160dbd --- /dev/null +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -0,0 +1,209 @@ +/** + * 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, { act, useRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +import { OptimizelyContext } from '../provider/OptimizelyProvider'; +import { ProviderStateStore } from '../provider/ProviderStateStore'; +import type { OptimizelyContextValue } from '../provider/types'; +import { useOptimizelyUserContext } from './useOptimizelyUserContext'; + +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('useOptimizelyUserContext must be used within an '); + + consoleSpy.mockRestore(); + }); + + it('should return null when no user context is set', () => { + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current).toBeNull(); + }); + + it('should return the current user context from the store', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current).toBe(mockUserContext); + }); + + it('should update when user context changes', () => { + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current).toBeNull(); + + const mockUserContext = createMockUserContext('user-1'); + act(() => { + store.setUserContext(mockUserContext); + }); + + expect(result.current).toBe(mockUserContext); + }); + + it('should update when user context changes to a different user', () => { + const userContext1 = createMockUserContext('user-1'); + store.setUserContext(userContext1); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current).toBe(userContext1); + + const userContext2 = createMockUserContext('user-2'); + act(() => { + store.setUserContext(userContext2); + }); + + expect(result.current).toBe(userContext2); + }); + + it('should update to null when store is reset', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + expect(result.current).toBe(mockUserContext); + + act(() => { + store.reset(); + }); + + expect(result.current).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 ctx = useOptimizelyUserContext(); + const renderCount = useRenderCount(); + capturedRenderCount = renderCount; + return
{ctx?.getUserId()}
; + } + + const contextValue: OptimizelyContextValue = { + store, + client: {} as OptimizelyContextValue['client'], + }; + + render( + + + + ); + + const initialRenderCount = capturedRenderCount; + + // Changing isClientReady triggers a store notification, + // but since userContext reference didn't change, React's useState + // bails out and skips the re-render + act(() => { + store.setClientReady(true); + }); + + expect(capturedRenderCount).toBe(initialRenderCount); + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + }); + + it('should return wrapped user context with forced decision methods', () => { + const mockUserContext = createMockUserContext(); + store.setUserContext(mockUserContext); + + const wrapper = createWrapper(store); + const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); + + // The returned context should be the same reference as what's in the store + // (already wrapped by ProviderStateStore.setUserContext) + expect(result.current).toBe(store.getState().userContext); + }); +}); diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts new file mode 100644 index 0000000..ab5a183 --- /dev/null +++ b/src/hooks/useOptimizelyUserContext.ts @@ -0,0 +1,59 @@ +/** + * 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, useState, useEffect, useRef } from 'react'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +import { OptimizelyContext } from '../provider/index'; + +/** + * Returns the current OptimizelyUserContext from the ProviderStateStore. + * + * The returned context has wrapped forced decision methods — calling + * `setForcedDecision()`, `removeForcedDecision()`, or `removeAllForcedDecisions()` + * on it will automatically trigger React re-renders in hooks watching the affected flags. + * + * Returns `null` while the SDK is initializing or if the user context has not been created yet. + */ +export function useOptimizelyUserContext(): OptimizelyUserContext | null { + const context = useContext(OptimizelyContext); + + if (!context) { + throw new Error('useOptimizelyUserContext must be used within an '); + } + + const { store } = context; + + const [userContext, setUserContext] = useState(() => store.getState().userContext); + // const userContextRef = useRef(userContext); + // userContextRef.current = userContext; + + useEffect(() => { + // // Sync in case state changed between render and effect + // const currentContext = store.getState().userContext; + // if (currentContext !== userContextRef.current) { + // setUserContext(currentContext); + // } + setUserContext(store.getState().userContext); + const unsubscribe = store.subscribe((state) => { + setUserContext(state.userContext); + }); + + return unsubscribe; + }, [store]); + + return userContext; +} diff --git a/src/index.ts b/src/index.ts index 63e9645..6ae184d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,3 +32,6 @@ export type * from "@optimizely/optimizely-sdk"; // Todo: Remove OptimizelyContext export in future export { OptimizelyProvider } from './provider/index'; export type { UserInfo, OptimizelyProviderProps } from './provider/index'; + +// Hooks +export { useOptimizelyUserContext } from './hooks/index'; 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/**'], }, }, }); From 09a1f5f647042228d76f7f7fe6678db61335369d Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:41:44 +0600 Subject: [PATCH 02/10] [fssdk-12295] update --- package-lock.json | 18 +++++++++++++----- package.json | 4 ++-- src/hooks/useOptimizelyUserContext.ts | 7 ------- src/index.ts | 4 ++-- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5704c48..3a1899e 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", @@ -6132,6 +6132,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 +7845,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 +8493,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 +9463,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 +9489,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 +11057,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..aa672de 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" diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts index ab5a183..5002ab5 100644 --- a/src/hooks/useOptimizelyUserContext.ts +++ b/src/hooks/useOptimizelyUserContext.ts @@ -38,15 +38,8 @@ export function useOptimizelyUserContext(): OptimizelyUserContext | null { const { store } = context; const [userContext, setUserContext] = useState(() => store.getState().userContext); - // const userContextRef = useRef(userContext); - // userContextRef.current = userContext; useEffect(() => { - // // Sync in case state changed between render and effect - // const currentContext = store.getState().userContext; - // if (currentContext !== userContextRef.current) { - // setUserContext(currentContext); - // } setUserContext(store.getState().userContext); const unsubscribe = store.subscribe((state) => { setUserContext(state.userContext); diff --git a/src/index.ts b/src/index.ts index 6ae184d..5507457 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,10 +23,10 @@ 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 From 26468306744af04c3ea7f38907f2cde7d17c3426 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:39:08 +0600 Subject: [PATCH 03/10] [FSSDK-12295] useOptimizelyUserContext impl. --- package-lock.json | 8 ++++++ package.json | 1 + src/hooks/useOptimizelyUserContext.spec.tsx | 23 ++++-------------- src/hooks/useOptimizelyUserContext.ts | 27 +++++++++------------ 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a1899e..994e298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index aa672de..662f2c5 100644 --- a/package.json +++ b/package.json @@ -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/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx index 2160dbd..b3778cb 100644 --- a/src/hooks/useOptimizelyUserContext.spec.tsx +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -15,15 +15,14 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import React, { act, useRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { render, screen, act } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; -import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; - import { OptimizelyContext } from '../provider/OptimizelyProvider'; import { ProviderStateStore } from '../provider/ProviderStateStore'; -import type { OptimizelyContextValue } from '../provider/types'; import { useOptimizelyUserContext } from './useOptimizelyUserContext'; +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; +import type { OptimizelyContextValue } from '../provider/types'; function useRenderCount() { const renderCount = useRef(0); @@ -97,7 +96,7 @@ describe('useOptimizelyUserContext', () => { expect(result.current).toBe(mockUserContext); }); - it('should update when user context changes', () => { + it('should update when user context changes', async () => { const wrapper = createWrapper(store); const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); @@ -194,16 +193,4 @@ describe('useOptimizelyUserContext', () => { expect(capturedRenderCount).toBe(initialRenderCount); expect(screen.getByTestId('user-id').textContent).toBe('test-user'); }); - - it('should return wrapped user context with forced decision methods', () => { - const mockUserContext = createMockUserContext(); - store.setUserContext(mockUserContext); - - const wrapper = createWrapper(store); - const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); - - // The returned context should be the same reference as what's in the store - // (already wrapped by ProviderStateStore.setUserContext) - expect(result.current).toBe(store.getState().userContext); - }); }); diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts index 5002ab5..938e8d6 100644 --- a/src/hooks/useOptimizelyUserContext.ts +++ b/src/hooks/useOptimizelyUserContext.ts @@ -14,20 +14,22 @@ * limitations under the License. */ -import { useContext, useState, useEffect, useRef } from 'react'; +import { useContext, useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; import { OptimizelyContext } from '../provider/index'; /** - * Returns the current OptimizelyUserContext from the ProviderStateStore. + * Returns the current {@link OptimizelyUserContext} for the nearest ``. * - * The returned context has wrapped forced decision methods — calling - * `setForcedDecision()`, `removeForcedDecision()`, or `removeAllForcedDecisions()` - * on it will automatically trigger React re-renders in hooks watching the affected flags. + * The user context gives access to the user's identity (user ID and attributes) + * and methods for working with forced decisions (`setForcedDecision`, + * `removeForcedDecision`, `removeAllForcedDecisions`). * - * Returns `null` while the SDK is initializing or if the user context has not been created yet. + * Returns `null` while the SDK is initializing or if no user has been set yet. */ + export function useOptimizelyUserContext(): OptimizelyUserContext | null { const context = useContext(OptimizelyContext); @@ -37,16 +39,9 @@ export function useOptimizelyUserContext(): OptimizelyUserContext | null { const { store } = context; - const [userContext, setUserContext] = useState(() => store.getState().userContext); - - useEffect(() => { - setUserContext(store.getState().userContext); - const unsubscribe = store.subscribe((state) => { - setUserContext(state.userContext); - }); + const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); - return unsubscribe; - }, [store]); + const getSnapshot = useCallback(() => store.getState().userContext, [store]); - return userContext; + return useSyncExternalStore(subscribe, getSnapshot); } From 33ca8f5da01d0281814179e12e415160db0ce065 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:32:58 +0600 Subject: [PATCH 04/10] [FSSDK-12295] user context hook update --- rollup.config.mjs | 3 ++- src/hooks/useOptimizelyUserContext.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts index 938e8d6..3bc42e4 100644 --- a/src/hooks/useOptimizelyUserContext.ts +++ b/src/hooks/useOptimizelyUserContext.ts @@ -43,5 +43,5 @@ export function useOptimizelyUserContext(): OptimizelyUserContext | null { const getSnapshot = useCallback(() => store.getState().userContext, [store]); - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } From 6c0b314e3869f06a25762b3b6a8cc32295bad5b7 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:10:15 +0600 Subject: [PATCH 05/10] [FSSDK-12295] client hook update --- src/hooks/index.ts | 1 + src/hooks/useOptimizelyClient.spec.tsx | 93 ++++++++++++++++++++++++++ src/hooks/useOptimizelyClient.ts | 32 +++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/hooks/useOptimizelyClient.spec.tsx create mode 100644 src/hooks/useOptimizelyClient.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 64f691d..d15ea21 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export { useOptimizelyClient } from './useOptimizelyClient'; export { useOptimizelyUserContext } from './useOptimizelyUserContext'; diff --git a/src/hooks/useOptimizelyClient.spec.tsx b/src/hooks/useOptimizelyClient.spec.tsx new file mode 100644 index 0000000..6e90e0b --- /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('useOptimizelyClient 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..459690d --- /dev/null +++ b/src/hooks/useOptimizelyClient.ts @@ -0,0 +1,32 @@ +/** + * 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 { Client } from '@optimizely/optimizely-sdk'; + +/** + * Returns the Optimizely client instance from the nearest ``. + */ +export function useOptimizelyClient(): Client { + const context = useContext(OptimizelyContext); + + if (!context) { + throw new Error('useOptimizelyClient must be used within an '); + } + + return context.client; +} From 82c5e439e220c9a38db781c2affa0ba6944504db Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:18:29 +0600 Subject: [PATCH 06/10] [FSSDK-12293] useDecide m1 --- src/hooks/index.ts | 2 + src/hooks/useDecide.spec.tsx | 360 ++++++++++++++++++++ src/hooks/useDecide.ts | 76 +++++ src/hooks/useOptimizelyClient.spec.tsx | 2 +- src/hooks/useOptimizelyClient.ts | 11 +- src/hooks/useOptimizelyContext.ts | 36 ++ src/hooks/useOptimizelyUserContext.spec.tsx | 2 +- src/hooks/useOptimizelyUserContext.ts | 13 +- src/hooks/useStableArray.ts | 44 +++ src/utils/helpers.ts | 16 + src/utils/index.ts | 2 +- 11 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 src/hooks/useDecide.spec.tsx create mode 100644 src/hooks/useDecide.ts create mode 100644 src/hooks/useOptimizelyContext.ts create mode 100644 src/hooks/useStableArray.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d15ea21..f08efa4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -16,3 +16,5 @@ export { useOptimizelyClient } from './useOptimizelyClient'; export { useOptimizelyUserContext } 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..df83d22 --- /dev/null +++ b/src/hooks/useDecide.spec.tsx @@ -0,0 +1,360 @@ +/** + * 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 { 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(), + getForcedDecision: vi.fn(), + removeForcedDecision: vi.fn(), + removeAllForcedDecisions: vi.fn(), + 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}; + }; +} + +function useRenderCount() { + const renderCount = useRef(0); + return ++renderCount.current; +} + +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.enabled).toBe(false); + expect(result.current.decision.variationKey).toBeNull(); + expect(result.current.decision.flagKey).toBe('flag_1'); + }); + + 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 default decision while loading', () => { + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('my_flag'), { wrapper }); + + const { decision } = result.current; + expect(decision.enabled).toBe(false); + expect(decision.variationKey).toBeNull(); + expect(decision.ruleKey).toBeNull(); + expect(decision.variables).toEqual({}); + expect(decision.flagKey).toBe('my_flag'); + expect(decision.reasons).toContain('Optimizely SDK not configured properly yet.'); + }); + + 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)', () => { + mockClient = createMockClient(true); + const wrapper = createWrapper(store, mockClient); + const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + const mockUserContext = createMockUserContext(); + act(() => { + store.setUserContext(mockUserContext); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.decision).toBe(MOCK_DECISION); + }); + + it('should re-evaluate when setClientReady fire', () => { + 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' }); + act(() => { + 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', () => { + 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'); + act(() => { + store.setError(testError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(testError); + expect(result.current.decision.enabled).toBe(false); + expect(result.current.decision.variationKey).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 update default decision flagKey when flagKey changes', () => { + const wrapper = createWrapper(store, mockClient); + const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), { + wrapper, + initialProps: { flagKey: 'flag_a' }, + }); + + expect(result.current.decision.flagKey).toBe('flag_a'); + + rerender({ flagKey: 'flag_b' }); + + expect(result.current.decision.flagKey).toBe('flag_b'); + }); + + it('should re-call decide() when setClientReady fires after sync decision was already served', () => { + // 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. + act(() => { + store.setClientReady(true); + }); + + expect(mockUserContext.decide).toHaveBeenCalledTimes(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.decision).toBe(MOCK_DECISION); + }); +}); diff --git a/src/hooks/useDecide.ts b/src/hooks/useDecide.ts new file mode 100644 index 0000000..2aa1725 --- /dev/null +++ b/src/hooks/useDecide.ts @@ -0,0 +1,76 @@ +/** + * 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 { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/optimizely-sdk'; + +import { useOptimizelyContext } from './useOptimizelyContext'; +import { useStableArray } from './useStableArray'; +import { createDefaultDecision } from '../utils/helpers'; + +export interface UseDecideConfig { + decideOptions?: OptimizelyDecideOption[]; +} + +export interface UseDecideResult { + decision: OptimizelyDecision; + isLoading: boolean; + error: Error | null; +} + +/** + * 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); + const defaultDecision = useMemo( + () => createDefaultDecision(flagKey, 'Optimizely SDK not configured properly yet.'), + [flagKey] + ); + + // --- 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); + + // --- Derive decision --- + return useMemo(() => { + const { userContext, error } = state; + const hasConfig = client.getOptimizelyConfig() !== null; + + if (error) { + return { decision: defaultDecision, isLoading: false, error }; + } + + if (!hasConfig || userContext === null) { + return { decision: defaultDecision, isLoading: true, error: null }; + } + + const decision = userContext.decide(flagKey, decideOptions); + return { decision, isLoading: false, error: null }; + }, [state, client, flagKey, decideOptions, defaultDecision]); +} diff --git a/src/hooks/useOptimizelyClient.spec.tsx b/src/hooks/useOptimizelyClient.spec.tsx index 6e90e0b..c65ae36 100644 --- a/src/hooks/useOptimizelyClient.spec.tsx +++ b/src/hooks/useOptimizelyClient.spec.tsx @@ -40,7 +40,7 @@ describe('useOptimizelyClient', () => { expect(() => { renderHook(() => useOptimizelyClient()); - }).toThrow('useOptimizelyClient must be used within an '); + }).toThrow('Optimizely hooks must be used within an '); consoleSpy.mockRestore(); }); diff --git a/src/hooks/useOptimizelyClient.ts b/src/hooks/useOptimizelyClient.ts index 459690d..b81d50e 100644 --- a/src/hooks/useOptimizelyClient.ts +++ b/src/hooks/useOptimizelyClient.ts @@ -14,19 +14,12 @@ * limitations under the License. */ -import { useContext } from 'react'; -import { OptimizelyContext } from '../provider/index'; import type { Client } from '@optimizely/optimizely-sdk'; +import { useOptimizelyContext } from './useOptimizelyContext'; /** * Returns the Optimizely client instance from the nearest ``. */ export function useOptimizelyClient(): Client { - const context = useContext(OptimizelyContext); - - if (!context) { - throw new Error('useOptimizelyClient must be used within an '); - } - - return context.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 index b3778cb..f2a0c17 100644 --- a/src/hooks/useOptimizelyUserContext.spec.tsx +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -74,7 +74,7 @@ describe('useOptimizelyUserContext', () => { expect(() => { renderHook(() => useOptimizelyUserContext()); - }).toThrow('useOptimizelyUserContext must be used within an '); + }).toThrow('Optimizely hooks must be used within an '); consoleSpy.mockRestore(); }); diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts index 3bc42e4..6658e21 100644 --- a/src/hooks/useOptimizelyUserContext.ts +++ b/src/hooks/useOptimizelyUserContext.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { useContext, useCallback } from 'react'; +import { useCallback } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; -import { OptimizelyContext } from '../provider/index'; +import { useOptimizelyContext } from './useOptimizelyContext'; /** * Returns the current {@link OptimizelyUserContext} for the nearest ``. @@ -29,15 +29,8 @@ import { OptimizelyContext } from '../provider/index'; * * Returns `null` while the SDK is initializing or if no user has been set yet. */ - export function useOptimizelyUserContext(): OptimizelyUserContext | null { - const context = useContext(OptimizelyContext); - - if (!context) { - throw new Error('useOptimizelyUserContext must be used within an '); - } - - const { store } = context; + const { store } = useOptimizelyContext(); const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]); diff --git a/src/hooks/useStableArray.ts b/src/hooks/useStableArray.ts new file mode 100644 index 0000000..0d4edeb --- /dev/null +++ b/src/hooks/useStableArray.ts @@ -0,0 +1,44 @@ +/** + * 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; + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + + return true; +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 7d43df2..7a7f4e1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -15,6 +15,22 @@ */ import type { UserInfo } from '../provider/types'; +import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +/** + * Creates a default decision to return while loading or when an error occurs. + */ +export function createDefaultDecision(flagKey: string, reason: string): OptimizelyDecision { + return { + variationKey: null, + enabled: false, + variables: {}, + ruleKey: null, + flagKey, + userContext: { id: null, attributes: {} } as unknown as OptimizelyUserContext, + reasons: [reason], + }; +} /** * Compares two string arrays for value equality (order-insensitive). 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'; From 531a331c817d66856a3924b41c224a2f7495bf14 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:35:07 +0600 Subject: [PATCH 07/10] [FSSDK-12293] react warning bad setState fix --- src/hooks/useDecide.spec.tsx | 16 +++---- src/hooks/useOptimizelyUserContext.spec.tsx | 10 ++--- src/index.ts | 2 +- src/provider/ProviderStateStore.spec.ts | 50 +++++++++++++++++---- src/provider/ProviderStateStore.ts | 19 +++++++- 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/hooks/useDecide.spec.tsx b/src/hooks/useDecide.spec.tsx index df83d22..eb13b4f 100644 --- a/src/hooks/useDecide.spec.tsx +++ b/src/hooks/useDecide.spec.tsx @@ -172,7 +172,7 @@ describe('useDecide', () => { expect(mockUserContext.decide).toHaveBeenCalledWith('flag_1', decideOptions); }); - it('should re-evaluate when store state changes (user context set after mount)', () => { + 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 }); @@ -180,7 +180,7 @@ describe('useDecide', () => { expect(result.current.isLoading).toBe(true); const mockUserContext = createMockUserContext(); - act(() => { + await act(async () => { store.setUserContext(mockUserContext); }); @@ -188,7 +188,7 @@ describe('useDecide', () => { expect(result.current.decision).toBe(MOCK_DECISION); }); - it('should re-evaluate when setClientReady fire', () => { + it('should re-evaluate when setClientReady fire', async () => { const mockUserContext = createMockUserContext(); store.setUserContext(mockUserContext); // Client has no config yet @@ -199,7 +199,7 @@ describe('useDecide', () => { // Simulate config becoming available when onReady resolves (mockClient.getOptimizelyConfig as ReturnType).mockReturnValue({ revision: '1' }); - act(() => { + await act(async () => { store.setClientReady(true); }); @@ -207,14 +207,14 @@ describe('useDecide', () => { expect(result.current.decision).toBe(MOCK_DECISION); }); - it('should return error from store with isLoading: false', () => { + 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'); - act(() => { + await act(async () => { store.setError(testError); }); @@ -331,7 +331,7 @@ describe('useDecide', () => { expect(result.current.decision.flagKey).toBe('flag_b'); }); - it('should re-call decide() when setClientReady fires after sync decision was already served', () => { + 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(); @@ -349,7 +349,7 @@ describe('useDecide', () => { // 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. - act(() => { + await act(async () => { store.setClientReady(true); }); diff --git a/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx index f2a0c17..387b03f 100644 --- a/src/hooks/useOptimizelyUserContext.spec.tsx +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -103,14 +103,14 @@ describe('useOptimizelyUserContext', () => { expect(result.current).toBeNull(); const mockUserContext = createMockUserContext('user-1'); - act(() => { + await act(async () => { store.setUserContext(mockUserContext); }); expect(result.current).toBe(mockUserContext); }); - it('should update when user context changes to a different user', () => { + it('should update when user context changes to a different user', async () => { const userContext1 = createMockUserContext('user-1'); store.setUserContext(userContext1); @@ -120,14 +120,14 @@ describe('useOptimizelyUserContext', () => { expect(result.current).toBe(userContext1); const userContext2 = createMockUserContext('user-2'); - act(() => { + await act(async () => { store.setUserContext(userContext2); }); expect(result.current).toBe(userContext2); }); - it('should update to null when store is reset', () => { + it('should update to null when store is reset', async () => { const mockUserContext = createMockUserContext(); store.setUserContext(mockUserContext); @@ -136,7 +136,7 @@ describe('useOptimizelyUserContext', () => { expect(result.current).toBe(mockUserContext); - act(() => { + await act(async () => { store.reset(); }); diff --git a/src/index.ts b/src/index.ts index 5507457..5dcae0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,4 +34,4 @@ export { OptimizelyProvider } from './provider/index'; export type { UserInfo, OptimizelyProviderProps } from './provider/index'; // Hooks -export { useOptimizelyUserContext } from './hooks/index'; +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); + }); }); } } From 655d6f527d6c4ceb84368c216ab45ee47b96de47 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:24:40 +0600 Subject: [PATCH 08/10] [FSSDK-12293] useDecide wrap up --- src/hooks/useDecide.spec.tsx | 141 ++++++++++++++++++++++++++++++-- src/hooks/useDecide.ts | 23 ++++-- src/provider/types.ts | 2 +- src/utils/UserContextManager.ts | 14 ++-- src/utils/helpers.ts | 4 +- 5 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src/hooks/useDecide.spec.tsx b/src/hooks/useDecide.spec.tsx index eb13b4f..9c5b618 100644 --- a/src/hooks/useDecide.spec.tsx +++ b/src/hooks/useDecide.spec.tsx @@ -15,7 +15,7 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import React, { useRef } from 'react'; +import React from 'react'; import { act } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; import { OptimizelyContext, ProviderStateStore } from '../provider/index'; @@ -46,10 +46,10 @@ function createMockUserContext(overrides?: Partial>): decide: vi.fn().mockReturnValue(MOCK_DECISION), decideAll: vi.fn(), decideForKeys: vi.fn(), - setForcedDecision: vi.fn(), + setForcedDecision: vi.fn().mockReturnValue(true), getForcedDecision: vi.fn(), - removeForcedDecision: vi.fn(), - removeAllForcedDecisions: vi.fn(), + removeForcedDecision: vi.fn().mockReturnValue(true), + removeAllForcedDecisions: vi.fn().mockReturnValue(true), trackEvent: vi.fn(), getOptimizely: vi.fn(), setQualifiedSegments: vi.fn(), @@ -76,11 +76,6 @@ function createWrapper(store: ProviderStateStore, client: Client) { }; } -function useRenderCount() { - const renderCount = useRef(0); - return ++renderCount.current; -} - describe('useDecide', () => { let store: ProviderStateStore; let mockClient: Client; @@ -357,4 +352,132 @@ describe('useDecide', () => { 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 index 2aa1725..bfc46ce 100644 --- a/src/hooks/useDecide.ts +++ b/src/hooks/useDecide.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/optimizely-sdk'; @@ -45,10 +45,7 @@ export interface UseDecideResult { export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideResult { const { store, client } = useOptimizelyContext(); const decideOptions = useStableArray(config?.decideOptions); - const defaultDecision = useMemo( - () => createDefaultDecision(flagKey, 'Optimizely SDK not configured properly yet.'), - [flagKey] - ); + const defaultDecision = useMemo(() => createDefaultDecision(flagKey), [flagKey]); // --- General state subscription --- // store.getState() returns a new object on every state change, @@ -57,6 +54,17 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR 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(() => { const { userContext, error } = state; @@ -72,5 +80,8 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR const decision = userContext.decide(flagKey, decideOptions); return { decision, isLoading: false, error: null }; - }, [state, client, flagKey, decideOptions, defaultDecision]); + // fdVersion is not referenced in the callback but triggers recomputation + // when a forced decision changes for this flagKey. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state, fdVersion, client, flagKey, decideOptions, defaultDecision]); } diff --git a/src/provider/types.ts b/src/provider/types.ts index a02f5ad..205bb13 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -58,7 +58,7 @@ export interface OptimizelyProviderProps { * and a background fetch verifies them (unless skipSegments is true). * `undefined` = normal flow, `[]` = explicit "zero segments". */ - qualifiedSegments?: string[]; + qualifiedSegments?: string[] | null; /** * React children to render. diff --git a/src/utils/UserContextManager.ts b/src/utils/UserContextManager.ts index 2afd426..3ee08ec 100644 --- a/src/utils/UserContextManager.ts +++ b/src/utils/UserContextManager.ts @@ -47,7 +47,7 @@ export class UserContextManager { private initialized = false; private skipSegments = false; private prevUser?: UserInfo; - private prevSegments?: string[]; + private prevSegments?: string[] | null; constructor(config: UserContextManagerConfig) { this.client = config.client; @@ -66,7 +66,7 @@ export class UserContextManager { * @param qualifiedSegments - Optional pre-fetched segments. When provided, * @param skipSegments - Whether to skip ODP segment fetching (default: false) */ - resolveUserContext(user?: UserInfo, qualifiedSegments?: string[], skipSegments = false): void { + resolveUserContext(user?: UserInfo, qualifiedSegments?: string[] | null, skipSegments = false): void { if ( this.initialized && this.skipSegments === skipSegments && @@ -96,7 +96,11 @@ export class UserContextManager { this.disposed = true; } - private async createUserContext(requestId: number, user?: UserInfo, qualifiedSegments?: string[]): Promise { + private async createUserContext( + requestId: number, + user?: UserInfo, + qualifiedSegments?: string[] | null + ): Promise { if (!user?.id && this.meta.hasVuidManager) { await this.client.onReady(); if (this.isStale(requestId)) return; @@ -104,7 +108,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 @@ -118,7 +122,7 @@ export class UserContextManager { if (this.isStale(requestId)) return; if (this.client.isOdpIntegrated()) { - const snapshot = [...qualifiedSegments]; + const snapshot = qualifiedSegments ? [...qualifiedSegments] : null; await ctx.fetchQualifiedSegments(); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 7a7f4e1..6aedf6d 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -20,7 +20,7 @@ import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/opti /** * Creates a default decision to return while loading or when an error occurs. */ -export function createDefaultDecision(flagKey: string, reason: string): OptimizelyDecision { +export function createDefaultDecision(flagKey: string): OptimizelyDecision { return { variationKey: null, enabled: false, @@ -28,7 +28,7 @@ export function createDefaultDecision(flagKey: string, reason: string): Optimize ruleKey: null, flagKey, userContext: { id: null, attributes: {} } as unknown as OptimizelyUserContext, - reasons: [reason], + reasons: ['Optimizely SDK not configured properly yet.'], }; } From a85f71a735467e64770a5c410628fb6329707b1c Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:32:03 +0600 Subject: [PATCH 09/10] [FSSDK-12293] improvements --- src/hooks/useDecide.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/hooks/useDecide.ts b/src/hooks/useDecide.ts index bfc46ce..3062502 100644 --- a/src/hooks/useDecide.ts +++ b/src/hooks/useDecide.ts @@ -45,8 +45,6 @@ export interface UseDecideResult { export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideResult { const { store, client } = useOptimizelyContext(); const decideOptions = useStableArray(config?.decideOptions); - const defaultDecision = useMemo(() => createDefaultDecision(flagKey), [flagKey]); - // --- General state subscription --- // store.getState() returns a new object on every state change, // so Object.is comparison works naturally. @@ -67,21 +65,19 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR // --- 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: defaultDecision, isLoading: false, error }; + return { decision: createDefaultDecision(flagKey), isLoading: false, error }; } if (!hasConfig || userContext === null) { - return { decision: defaultDecision, isLoading: true, error: null }; + return { decision: createDefaultDecision(flagKey), isLoading: true, error: null }; } const decision = userContext.decide(flagKey, decideOptions); return { decision, isLoading: false, error: null }; - // fdVersion is not referenced in the callback but triggers recomputation - // when a forced decision changes for this flagKey. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state, fdVersion, client, flagKey, decideOptions, defaultDecision]); + }, [fdVersion, state, client, flagKey, decideOptions]); } From 16d44117b907cc6af9fa4e88d1af04fcc5acda0f Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:56:54 +0600 Subject: [PATCH 10/10] [FSSDK-12293] feedback addressed --- src/hooks/index.ts | 1 + src/hooks/useDecide.spec.tsx | 25 ++++------ src/hooks/useDecide.ts | 14 +++--- src/hooks/useOptimizelyUserContext.spec.tsx | 53 +++++++++++++++------ src/hooks/useOptimizelyUserContext.ts | 27 ++++++++--- src/hooks/useStableArray.ts | 7 ++- src/provider/types.ts | 2 +- src/utils/UserContextManager.ts | 12 ++--- src/utils/helpers.ts | 18 +------ 9 files changed, 86 insertions(+), 73 deletions(-) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f08efa4..2ef9b8b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -16,5 +16,6 @@ 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 index 9c5b618..eeb6967 100644 --- a/src/hooks/useDecide.spec.tsx +++ b/src/hooks/useDecide.spec.tsx @@ -102,9 +102,7 @@ describe('useDecide', () => { expect(result.current.isLoading).toBe(true); expect(result.current.error).toBeNull(); - expect(result.current.decision.enabled).toBe(false); - expect(result.current.decision.variationKey).toBeNull(); - expect(result.current.decision.flagKey).toBe('flag_1'); + expect(result.current.decision).toBeNull(); }); it('should return isLoading: true when config is available but no user context', () => { @@ -127,17 +125,13 @@ describe('useDecide', () => { expect(result.current.error).toBeNull(); }); - it('should return default decision while loading', () => { + it('should return null decision while loading', () => { const wrapper = createWrapper(store, mockClient); const { result } = renderHook(() => useDecide('my_flag'), { wrapper }); - const { decision } = result.current; - expect(decision.enabled).toBe(false); - expect(decision.variationKey).toBeNull(); - expect(decision.ruleKey).toBeNull(); - expect(decision.variables).toEqual({}); - expect(decision.flagKey).toBe('my_flag'); - expect(decision.reasons).toContain('Optimizely SDK not configured properly yet.'); + 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', () => { @@ -215,8 +209,7 @@ describe('useDecide', () => { expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(testError); - expect(result.current.decision.enabled).toBe(false); - expect(result.current.decision.variationKey).toBeNull(); + expect(result.current.decision).toBeNull(); }); it('should re-evaluate when flagKey changes', () => { @@ -312,18 +305,18 @@ describe('useDecide', () => { expect(mockUserContext.decide).not.toHaveBeenCalled(); }); - it('should update default decision flagKey when flagKey changes', () => { + 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.flagKey).toBe('flag_a'); + expect(result.current.decision).toBeNull(); rerender({ flagKey: 'flag_b' }); - expect(result.current.decision.flagKey).toBe('flag_b'); + expect(result.current.decision).toBeNull(); }); it('should re-call decide() when setClientReady fires after sync decision was already served', async () => { diff --git a/src/hooks/useDecide.ts b/src/hooks/useDecide.ts index 3062502..7ab4350 100644 --- a/src/hooks/useDecide.ts +++ b/src/hooks/useDecide.ts @@ -20,17 +20,15 @@ import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/opt import { useOptimizelyContext } from './useOptimizelyContext'; import { useStableArray } from './useStableArray'; -import { createDefaultDecision } from '../utils/helpers'; export interface UseDecideConfig { decideOptions?: OptimizelyDecideOption[]; } -export interface UseDecideResult { - decision: OptimizelyDecision; - isLoading: boolean; - error: Error | null; -} +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. @@ -70,11 +68,11 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR const hasConfig = client.getOptimizelyConfig() !== null; if (error) { - return { decision: createDefaultDecision(flagKey), isLoading: false, error }; + return { decision: null, isLoading: false, error }; } if (!hasConfig || userContext === null) { - return { decision: createDefaultDecision(flagKey), isLoading: true, error: null }; + return { decision: null, isLoading: true, error: null }; } const decision = userContext.decide(flagKey, decideOptions); diff --git a/src/hooks/useOptimizelyUserContext.spec.tsx b/src/hooks/useOptimizelyUserContext.spec.tsx index 387b03f..ec35bb2 100644 --- a/src/hooks/useOptimizelyUserContext.spec.tsx +++ b/src/hooks/useOptimizelyUserContext.spec.tsx @@ -79,35 +79,41 @@ describe('useOptimizelyUserContext', () => { consoleSpy.mockRestore(); }); - it('should return null when no user context is set', () => { + 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).toBeNull(); + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.userContext).toBeNull(); }); - it('should return the current user context from the store', () => { + 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).toBe(mockUserContext); + 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).toBeNull(); + 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).toBe(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 () => { @@ -117,30 +123,47 @@ describe('useOptimizelyUserContext', () => { const wrapper = createWrapper(store); const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper }); - expect(result.current).toBe(userContext1); + expect(result.current.userContext).toBe(userContext1); const userContext2 = createMockUserContext('user-2'); await act(async () => { store.setUserContext(userContext2); }); - expect(result.current).toBe(userContext2); + expect(result.current.userContext).toBe(userContext2); }); - it('should update to null when store is reset', async () => { + 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).toBe(mockUserContext); + expect(result.current.userContext).toBe(mockUserContext); await act(async () => { store.reset(); }); - expect(result.current).toBeNull(); + 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', () => { @@ -164,10 +187,10 @@ describe('useOptimizelyUserContext', () => { let capturedRenderCount = 0; function TestComponent() { - const ctx = useOptimizelyUserContext(); + const { userContext } = useOptimizelyUserContext(); const renderCount = useRenderCount(); capturedRenderCount = renderCount; - return
{ctx?.getUserId()}
; + return
{userContext?.getUserId()}
; } const contextValue: OptimizelyContextValue = { @@ -184,8 +207,8 @@ describe('useOptimizelyUserContext', () => { const initialRenderCount = capturedRenderCount; // Changing isClientReady triggers a store notification, - // but since userContext reference didn't change, React's useState - // bails out and skips the re-render + // but since the derived result hasn't changed, useMemo returns + // the same reference and React bails out act(() => { store.setClientReady(true); }); diff --git a/src/hooks/useOptimizelyUserContext.ts b/src/hooks/useOptimizelyUserContext.ts index 6658e21..a03a32c 100644 --- a/src/hooks/useOptimizelyUserContext.ts +++ b/src/hooks/useOptimizelyUserContext.ts @@ -14,27 +14,42 @@ * limitations under the License. */ -import { useCallback } from 'react'; +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`). - * - * Returns `null` while the SDK is initializing or if no user has been set yet. */ -export function useOptimizelyUserContext(): OptimizelyUserContext | null { +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 }; + } - const getSnapshot = useCallback(() => store.getState().userContext, [store]); + if (userContext === null) { + return { userContext: null, isLoading: true, error: null }; + } - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return { userContext, isLoading: false, error: null }; + }, [state]); } diff --git a/src/hooks/useStableArray.ts b/src/hooks/useStableArray.ts index 0d4edeb..f7648e9 100644 --- a/src/hooks/useStableArray.ts +++ b/src/hooks/useStableArray.ts @@ -36,8 +36,11 @@ function shallowEqualArrays(a: T[] | undefined, b: T[] | undefined): boolean if (a === undefined || b === undefined) return false; if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) 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/provider/types.ts b/src/provider/types.ts index 205bb13..a02f5ad 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -58,7 +58,7 @@ export interface OptimizelyProviderProps { * and a background fetch verifies them (unless skipSegments is true). * `undefined` = normal flow, `[]` = explicit "zero segments". */ - qualifiedSegments?: string[] | null; + qualifiedSegments?: string[]; /** * React children to render. diff --git a/src/utils/UserContextManager.ts b/src/utils/UserContextManager.ts index 3ee08ec..cc1c1e3 100644 --- a/src/utils/UserContextManager.ts +++ b/src/utils/UserContextManager.ts @@ -47,7 +47,7 @@ export class UserContextManager { private initialized = false; private skipSegments = false; private prevUser?: UserInfo; - private prevSegments?: string[] | null; + private prevSegments?: string[]; constructor(config: UserContextManagerConfig) { this.client = config.client; @@ -66,7 +66,7 @@ export class UserContextManager { * @param qualifiedSegments - Optional pre-fetched segments. When provided, * @param skipSegments - Whether to skip ODP segment fetching (default: false) */ - resolveUserContext(user?: UserInfo, qualifiedSegments?: string[] | null, skipSegments = false): void { + resolveUserContext(user?: UserInfo, qualifiedSegments?: string[], skipSegments = false): void { if ( this.initialized && this.skipSegments === skipSegments && @@ -96,11 +96,7 @@ export class UserContextManager { this.disposed = true; } - private async createUserContext( - requestId: number, - user?: UserInfo, - qualifiedSegments?: string[] | null - ): Promise { + private async createUserContext(requestId: number, user?: UserInfo, qualifiedSegments?: string[]): Promise { if (!user?.id && this.meta.hasVuidManager) { await this.client.onReady(); if (this.isStale(requestId)) return; @@ -122,7 +118,7 @@ export class UserContextManager { if (this.isStale(requestId)) return; if (this.client.isOdpIntegrated()) { - const snapshot = qualifiedSegments ? [...qualifiedSegments] : null; + const snapshot = [...qualifiedSegments]; await ctx.fetchQualifiedSegments(); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 6aedf6d..6414b3e 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -15,29 +15,13 @@ */ import type { UserInfo } from '../provider/types'; -import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; - -/** - * Creates a default decision to return while loading or when an error occurs. - */ -export function createDefaultDecision(flagKey: string): OptimizelyDecision { - return { - variationKey: null, - enabled: false, - variables: {}, - ruleKey: null, - flagKey, - userContext: { id: null, attributes: {} } as unknown as OptimizelyUserContext, - reasons: ['Optimizely SDK not configured properly yet.'], - }; -} /** * Compares two string arrays for value equality (order-insensitive). * 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;