Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export interface UseDecideConfig {

export type UseDecideResult =
| { isLoading: true; error: null; decision: null }
| { isLoading: false; error: Error; decision: null }
| { isLoading: false; error: Error; decision: OptimizelyDecision | null }
| { isLoading: false; error: null; decision: OptimizelyDecision };

export type UseDecideMultiResult =
| { isLoading: true; error: null; decisions: Record<string, never> }
| { isLoading: false; error: Error; decisions: Record<string, never> }
| { isLoading: false; error: Error; decisions: Record<string, OptimizelyDecision> }
| { isLoading: false; error: null; decisions: Record<string, OptimizelyDecision> };
32 changes: 19 additions & 13 deletions src/hooks/useAsyncDecision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk';

import type { Client } from '@optimizely/optimizely-sdk';
Expand Down Expand Up @@ -52,34 +52,40 @@ export function useAsyncDecision<TResult>(
isLoading: true,
});

const prevUserContextRef = useRef<OptimizelyUserContext | null>(null);

useEffect(() => {
const { userContext, error } = state;
const hasConfig = client.getOptimizelyConfig() !== null;

// Store-level error — no async call needed
if (error) {
setAsyncState({ result: emptyResult, error, isLoading: false });
// No decision possible — surface store error or stay loading
if (!hasConfig || userContext === null) {
if (error) {
setAsyncState({ result: emptyResult, error, isLoading: false });
} else {
setAsyncState((prev) => {
if (prev.isLoading) return prev;
return { result: emptyResult, error: null, isLoading: true };
});
}
return;
}

// Ensure loading state (skip if already loading to avoid re-render)
const userContextChanged = userContext !== prevUserContextRef.current;
prevUserContextRef.current = userContext;

setAsyncState((prev) => {
if (prev.isLoading) return prev;
return { result: emptyResult, error: null, isLoading: true };
return { result: userContextChanged ? emptyResult : prev.result, error: null, isLoading: true };
});

// Store not ready — wait for config/user context
if (!hasConfig || userContext === null) {
return;
}

// Store is ready — fire async decision
// Config + userContext available — fire async decision even if store has error
let cancelled = false;

execute(userContext).then(
(result) => {
if (!cancelled) {
setAsyncState({ result, error: null, isLoading: false });
setAsyncState({ result, error, isLoading: false });
}
},
(err) => {
Expand Down
24 changes: 23 additions & 1 deletion src/hooks/useDecide.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('useDecide', () => {
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should return error from store with isLoading: false', async () => {
it('should return error from store with isLoading: false when no decision is possible', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });

Expand All @@ -145,6 +145,28 @@ describe('useDecide', () => {
expect(result.current.decision).toBeNull();
});

it('should return stale decision alongside error when config and user context are available', async () => {
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.decision).toBe(MOCK_DECISION);
expect(result.current.error).toBeNull();

const testError = new Error('CDN datafile fetch failed');
await act(async () => {
store.setError(testError);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(testError);
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should re-evaluate when flagKey changes', () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
Expand Down
12 changes: 6 additions & 6 deletions src/hooks/useDecide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR
const { userContext, error } = state;
const hasConfig = client.getOptimizelyConfig() !== null;

if (error) {
return { decision: null, isLoading: false, error };
if (hasConfig && userContext !== null) {
const decision = userContext.decide(flagKey, decideOptions);
return { decision, isLoading: false, error };
}
Comment thread
junaed-optimizely marked this conversation as resolved.

if (!hasConfig || userContext === null) {
return { decision: null, isLoading: true, error: null };
if (error) {
return { decision: null, isLoading: false, error };
}

const decision = userContext.decide(flagKey, decideOptions);
return { decision, isLoading: false, error: null };
return { decision: null, isLoading: true, error: null };
}, [fdVersion, state, client, flagKey, decideOptions]);
}
24 changes: 23 additions & 1 deletion src/hooks/useDecideAll.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('useDecideAll', () => {
expect(mockUserContext.decideAll).toHaveBeenCalledWith(decideOptions);
});

it('should return error from store with isLoading: false', async () => {
it('should return error from store with isLoading: false when no decision is possible', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAll(), { wrapper });

Expand All @@ -128,6 +128,28 @@ describe('useDecideAll', () => {
expect(result.current.decisions).toEqual({});
});

it('should return stale decisions alongside error when config and user context are available', async () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAll(), { wrapper });

expect(result.current.isLoading).toBe(false);
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
expect(result.current.error).toBeNull();

const testError = new Error('CDN datafile fetch failed');
await act(async () => {
store.setError(testError);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(testError);
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
});

it('should not call decideAll() while loading', () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
Expand Down
13 changes: 7 additions & 6 deletions src/hooks/useDecideAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { useEffect, useMemo, useState } from 'react';
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';

import { useOptimizelyContext } from './useOptimizelyContext';
import { useProviderState } from './useProviderState';
Expand Down Expand Up @@ -47,15 +48,15 @@ export function useDecideAll(config?: UseDecideConfig): UseDecideMultiResult {
const { userContext, error } = state;
const hasConfig = client.getOptimizelyConfig() !== null;

if (error) {
return { decisions: {} as Record<string, never>, isLoading: false as const, error };
if (hasConfig && userContext !== null) {
const decisions = userContext.decideAll(decideOptions);
return { decisions, isLoading: false as const, error };
Comment thread
junaed-optimizely marked this conversation as resolved.
}

if (!hasConfig || userContext === null) {
return { decisions: {} as Record<string, never>, isLoading: true as const, error: null };
if (error) {
return { decisions: {} as Record<string, OptimizelyDecision>, isLoading: false as const, error };
}

const decisions = userContext.decideAll(decideOptions);
return { decisions, isLoading: false as const, error: null };
return { decisions: {} as Record<string, never>, isLoading: true as const, error: null };
}, [fdVersion, state, client, decideOptions]);
}
30 changes: 29 additions & 1 deletion src/hooks/useDecideAllAsync.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('useDecideAllAsync', () => {
expect(result.current.error).toBeNull();
});

it('should return error from store with isLoading: false', async () => {
it('should return error from store with isLoading: false when no decision is possible', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });

Expand All @@ -84,6 +84,34 @@ describe('useDecideAllAsync', () => {
expect(result.current.decisions).toEqual({});
});

it('should return stale decisions alongside error when config and user context are available', async () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.decisions).toEqual(MOCK_DECISIONS);
expect(result.current.error).toBeNull();

const testError = new Error('CDN datafile fetch failed');
await act(async () => {
store.setError(testError);
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.error).toBe(testError);
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
});

it('should return isLoading: true while async call is in-flight', () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
Expand Down
56 changes: 55 additions & 1 deletion src/hooks/useDecideAsync.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('useDecideAsync', () => {
expect(result.current.error).toBeNull();
});

it('should return error from store with isLoading: false', async () => {
it('should return error from store with isLoading: false when no decision is possible', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });

Expand All @@ -84,6 +84,60 @@ describe('useDecideAsync', () => {
expect(result.current.decision).toBeNull();
});

it('should return stale decision alongside error when config and user context are available', async () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.decision).toBe(MOCK_DECISION);
expect(result.current.error).toBeNull();

const testError = new Error('CDN datafile fetch failed');
await act(async () => {
store.setError(testError);
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.error).toBe(testError);
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should preserve previous decision during re-execute when only store error changes', async () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideAsync('flag_1'), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.decision).toBe(MOCK_DECISION);

// Make next async call hang so we can observe intermediate state
(mockUserContext.decideAsync as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));

const testError = new Error('CDN refresh failed');
await act(async () => {
store.setError(testError);
});

// Should be loading but still have the previous decision, not null
expect(result.current.isLoading).toBe(true);
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should return isLoading: true while async call is in-flight', () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
Expand Down
24 changes: 23 additions & 1 deletion src/hooks/useDecideForKeys.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('useDecideForKeys', () => {
expect(mockUserContext.decideForKeys).toHaveBeenCalledWith(['flag_1'], decideOptions);
});

it('should return error from store with isLoading: false', async () => {
it('should return error from store with isLoading: false when no decision is possible', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideForKeys(['flag_1']), { wrapper });

Expand All @@ -128,6 +128,28 @@ describe('useDecideForKeys', () => {
expect(result.current.decisions).toEqual({});
});

it('should return stale decisions alongside error when config and user context are available', async () => {
mockClient = createMockClient(true);
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecideForKeys(['flag_1', 'flag_2']), { wrapper });

expect(result.current.isLoading).toBe(false);
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
expect(result.current.error).toBeNull();

const testError = new Error('CDN datafile fetch failed');
await act(async () => {
store.setError(testError);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(testError);
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
});

it('should not call decideForKeys() while loading', () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
Expand Down
14 changes: 8 additions & 6 deletions src/hooks/useDecideForKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/

import { useEffect, useMemo, useState } from 'react';
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';

import { useOptimizelyContext } from './useOptimizelyContext';
import { useProviderState } from './useProviderState';
import { useStableArray } from './useStableArray';
Expand Down Expand Up @@ -50,15 +52,15 @@ export function useDecideForKeys(flagKeys: string[], config?: UseDecideConfig):
const { userContext, error } = state;
const hasConfig = client.getOptimizelyConfig() !== null;

if (error) {
return { decisions: {}, isLoading: false, error };
if (hasConfig && userContext !== null) {
const decisions = userContext.decideForKeys(stableKeys, decideOptions);
return { decisions, isLoading: false as const, error };
Comment thread
junaed-optimizely marked this conversation as resolved.
}

if (!hasConfig || userContext === null) {
return { decisions: {}, isLoading: true, error: null };
if (error) {
return { decisions: {} as Record<string, OptimizelyDecision>, isLoading: false as const, error };
}

const decisions = userContext.decideForKeys(stableKeys, decideOptions);
return { decisions, isLoading: false as const, error: null };
return { decisions: {} as Record<string, never>, isLoading: true as const, error: null };
}, [fdVersion, state, client, stableKeys, decideOptions]);
}
Loading
Loading