');
+
+ 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;