Skip to content

Commit 16d4411

Browse files
[FSSDK-12293] feedback addressed
1 parent a85f71a commit 16d4411

9 files changed

Lines changed: 86 additions & 73 deletions

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616

1717
export { useOptimizelyClient } from './useOptimizelyClient';
1818
export { useOptimizelyUserContext } from './useOptimizelyUserContext';
19+
export type { UseOptimizelyUserContextResult } from './useOptimizelyUserContext';
1920
export { useDecide } from './useDecide';
2021
export type { UseDecideConfig, UseDecideResult } from './useDecide';

src/hooks/useDecide.spec.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,7 @@ describe('useDecide', () => {
102102

103103
expect(result.current.isLoading).toBe(true);
104104
expect(result.current.error).toBeNull();
105-
expect(result.current.decision.enabled).toBe(false);
106-
expect(result.current.decision.variationKey).toBeNull();
107-
expect(result.current.decision.flagKey).toBe('flag_1');
105+
expect(result.current.decision).toBeNull();
108106
});
109107

110108
it('should return isLoading: true when config is available but no user context', () => {
@@ -127,17 +125,13 @@ describe('useDecide', () => {
127125
expect(result.current.error).toBeNull();
128126
});
129127

130-
it('should return default decision while loading', () => {
128+
it('should return null decision while loading', () => {
131129
const wrapper = createWrapper(store, mockClient);
132130
const { result } = renderHook(() => useDecide('my_flag'), { wrapper });
133131

134-
const { decision } = result.current;
135-
expect(decision.enabled).toBe(false);
136-
expect(decision.variationKey).toBeNull();
137-
expect(decision.ruleKey).toBeNull();
138-
expect(decision.variables).toEqual({});
139-
expect(decision.flagKey).toBe('my_flag');
140-
expect(decision.reasons).toContain('Optimizely SDK not configured properly yet.');
132+
expect(result.current.decision).toBeNull();
133+
expect(result.current.isLoading).toBe(true);
134+
expect(result.current.error).toBeNull();
141135
});
142136

143137
it('should return actual decision when config and user context are available', () => {
@@ -215,8 +209,7 @@ describe('useDecide', () => {
215209

216210
expect(result.current.isLoading).toBe(false);
217211
expect(result.current.error).toBe(testError);
218-
expect(result.current.decision.enabled).toBe(false);
219-
expect(result.current.decision.variationKey).toBeNull();
212+
expect(result.current.decision).toBeNull();
220213
});
221214

222215
it('should re-evaluate when flagKey changes', () => {
@@ -312,18 +305,18 @@ describe('useDecide', () => {
312305
expect(mockUserContext.decide).not.toHaveBeenCalled();
313306
});
314307

315-
it('should update default decision flagKey when flagKey changes', () => {
308+
it('should return null decision for both flagKeys when loading', () => {
316309
const wrapper = createWrapper(store, mockClient);
317310
const { result, rerender } = renderHook(({ flagKey }) => useDecide(flagKey), {
318311
wrapper,
319312
initialProps: { flagKey: 'flag_a' },
320313
});
321314

322-
expect(result.current.decision.flagKey).toBe('flag_a');
315+
expect(result.current.decision).toBeNull();
323316

324317
rerender({ flagKey: 'flag_b' });
325318

326-
expect(result.current.decision.flagKey).toBe('flag_b');
319+
expect(result.current.decision).toBeNull();
327320
});
328321

329322
it('should re-call decide() when setClientReady fires after sync decision was already served', async () => {

src/hooks/useDecide.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,15 @@ import type { OptimizelyDecideOption, OptimizelyDecision } from '@optimizely/opt
2020

2121
import { useOptimizelyContext } from './useOptimizelyContext';
2222
import { useStableArray } from './useStableArray';
23-
import { createDefaultDecision } from '../utils/helpers';
2423

2524
export interface UseDecideConfig {
2625
decideOptions?: OptimizelyDecideOption[];
2726
}
2827

29-
export interface UseDecideResult {
30-
decision: OptimizelyDecision;
31-
isLoading: boolean;
32-
error: Error | null;
33-
}
28+
export type UseDecideResult =
29+
| { isLoading: true; error: null; decision: null }
30+
| { isLoading: false; error: Error; decision: null }
31+
| { isLoading: false; error: null; decision: OptimizelyDecision };
3432

3533
/**
3634
* Returns a feature flag decision for the given flag key.
@@ -70,11 +68,11 @@ export function useDecide(flagKey: string, config?: UseDecideConfig): UseDecideR
7068
const hasConfig = client.getOptimizelyConfig() !== null;
7169

7270
if (error) {
73-
return { decision: createDefaultDecision(flagKey), isLoading: false, error };
71+
return { decision: null, isLoading: false, error };
7472
}
7573

7674
if (!hasConfig || userContext === null) {
77-
return { decision: createDefaultDecision(flagKey), isLoading: true, error: null };
75+
return { decision: null, isLoading: true, error: null };
7876
}
7977

8078
const decision = userContext.decide(flagKey, decideOptions);

src/hooks/useOptimizelyUserContext.spec.tsx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,41 @@ describe('useOptimizelyUserContext', () => {
7979
consoleSpy.mockRestore();
8080
});
8181

82-
it('should return null when no user context is set', () => {
82+
it('should return isLoading: true with null userContext when no user context is set', () => {
8383
const wrapper = createWrapper(store);
8484
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
8585

86-
expect(result.current).toBeNull();
86+
expect(result.current.isLoading).toBe(true);
87+
expect(result.current.error).toBeNull();
88+
expect(result.current.userContext).toBeNull();
8789
});
8890

89-
it('should return the current user context from the store', () => {
91+
it('should return the current user context with isLoading: false', () => {
9092
const mockUserContext = createMockUserContext();
9193
store.setUserContext(mockUserContext);
9294

9395
const wrapper = createWrapper(store);
9496
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
9597

96-
expect(result.current).toBe(mockUserContext);
98+
expect(result.current.isLoading).toBe(false);
99+
expect(result.current.error).toBeNull();
100+
expect(result.current.userContext).toBe(mockUserContext);
97101
});
98102

99103
it('should update when user context changes', async () => {
100104
const wrapper = createWrapper(store);
101105
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
102106

103-
expect(result.current).toBeNull();
107+
expect(result.current.isLoading).toBe(true);
108+
expect(result.current.userContext).toBeNull();
104109

105110
const mockUserContext = createMockUserContext('user-1');
106111
await act(async () => {
107112
store.setUserContext(mockUserContext);
108113
});
109114

110-
expect(result.current).toBe(mockUserContext);
115+
expect(result.current.isLoading).toBe(false);
116+
expect(result.current.userContext).toBe(mockUserContext);
111117
});
112118

113119
it('should update when user context changes to a different user', async () => {
@@ -117,30 +123,47 @@ describe('useOptimizelyUserContext', () => {
117123
const wrapper = createWrapper(store);
118124
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
119125

120-
expect(result.current).toBe(userContext1);
126+
expect(result.current.userContext).toBe(userContext1);
121127

122128
const userContext2 = createMockUserContext('user-2');
123129
await act(async () => {
124130
store.setUserContext(userContext2);
125131
});
126132

127-
expect(result.current).toBe(userContext2);
133+
expect(result.current.userContext).toBe(userContext2);
128134
});
129135

130-
it('should update to null when store is reset', async () => {
136+
it('should return isLoading: true when store is reset', async () => {
131137
const mockUserContext = createMockUserContext();
132138
store.setUserContext(mockUserContext);
133139

134140
const wrapper = createWrapper(store);
135141
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
136142

137-
expect(result.current).toBe(mockUserContext);
143+
expect(result.current.userContext).toBe(mockUserContext);
138144

139145
await act(async () => {
140146
store.reset();
141147
});
142148

143-
expect(result.current).toBeNull();
149+
expect(result.current.isLoading).toBe(true);
150+
expect(result.current.userContext).toBeNull();
151+
});
152+
153+
it('should return error with isLoading: false when store has error', async () => {
154+
const wrapper = createWrapper(store);
155+
const { result } = renderHook(() => useOptimizelyUserContext(), { wrapper });
156+
157+
expect(result.current.isLoading).toBe(true);
158+
159+
const testError = new Error('SDK initialization failed');
160+
await act(async () => {
161+
store.setError(testError);
162+
});
163+
164+
expect(result.current.isLoading).toBe(false);
165+
expect(result.current.error).toBe(testError);
166+
expect(result.current.userContext).toBeNull();
144167
});
145168

146169
it('should unsubscribe from store on unmount', () => {
@@ -164,10 +187,10 @@ describe('useOptimizelyUserContext', () => {
164187

165188
let capturedRenderCount = 0;
166189
function TestComponent() {
167-
const ctx = useOptimizelyUserContext();
190+
const { userContext } = useOptimizelyUserContext();
168191
const renderCount = useRenderCount();
169192
capturedRenderCount = renderCount;
170-
return <div data-testid="user-id">{ctx?.getUserId()}</div>;
193+
return <div data-testid="user-id">{userContext?.getUserId()}</div>;
171194
}
172195

173196
const contextValue: OptimizelyContextValue = {
@@ -184,8 +207,8 @@ describe('useOptimizelyUserContext', () => {
184207
const initialRenderCount = capturedRenderCount;
185208

186209
// Changing isClientReady triggers a store notification,
187-
// but since userContext reference didn't change, React's useState
188-
// bails out and skips the re-render
210+
// but since the derived result hasn't changed, useMemo returns
211+
// the same reference and React bails out
189212
act(() => {
190213
store.setClientReady(true);
191214
});

src/hooks/useOptimizelyUserContext.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,42 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useCallback } from 'react';
17+
import { useCallback, useMemo } from 'react';
1818
import { useSyncExternalStore } from 'use-sync-external-store/shim';
1919
import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk';
2020

2121
import { useOptimizelyContext } from './useOptimizelyContext';
2222

23+
export type UseOptimizelyUserContextResult =
24+
| { isLoading: true; error: null; userContext: null }
25+
| { isLoading: false; error: Error; userContext: null }
26+
| { isLoading: false; error: null; userContext: OptimizelyUserContext };
27+
2328
/**
2429
* Returns the current {@link OptimizelyUserContext} for the nearest `<OptimizelyProvider>`.
2530
*
2631
* The user context gives access to the user's identity (user ID and attributes)
2732
* and methods for working with forced decisions (`setForcedDecision`,
2833
* `removeForcedDecision`, `removeAllForcedDecisions`).
29-
*
30-
* Returns `null` while the SDK is initializing or if no user has been set yet.
3134
*/
32-
export function useOptimizelyUserContext(): OptimizelyUserContext | null {
35+
export function useOptimizelyUserContext(): UseOptimizelyUserContextResult {
3336
const { store } = useOptimizelyContext();
3437

3538
const subscribe = useCallback((onStoreChange: () => void) => store.subscribe(onStoreChange), [store]);
39+
const getSnapshot = useCallback(() => store.getState(), [store]);
40+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
41+
42+
return useMemo(() => {
43+
const { userContext, error } = state;
44+
45+
if (error) {
46+
return { userContext: null, isLoading: false, error };
47+
}
3648

37-
const getSnapshot = useCallback(() => store.getState().userContext, [store]);
49+
if (userContext === null) {
50+
return { userContext: null, isLoading: true, error: null };
51+
}
3852

39-
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
53+
return { userContext, isLoading: false, error: null };
54+
}, [state]);
4055
}

src/hooks/useStableArray.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ function shallowEqualArrays<T>(a: T[] | undefined, b: T[] | undefined): boolean
3636
if (a === undefined || b === undefined) return false;
3737
if (a.length !== b.length) return false;
3838

39-
for (let i = 0; i < a.length; i++) {
40-
if (a[i] !== b[i]) return false;
39+
const sortedA = [...a].sort();
40+
const sortedB = [...b].sort();
41+
42+
for (let i = 0; i < sortedA.length; i++) {
43+
if (sortedA[i] !== sortedB[i]) return false;
4144
}
4245

4346
return true;

src/provider/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export interface OptimizelyProviderProps {
5858
* and a background fetch verifies them (unless skipSegments is true).
5959
* `undefined` = normal flow, `[]` = explicit "zero segments".
6060
*/
61-
qualifiedSegments?: string[] | null;
61+
qualifiedSegments?: string[];
6262

6363
/**
6464
* React children to render.

src/utils/UserContextManager.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class UserContextManager {
4747
private initialized = false;
4848
private skipSegments = false;
4949
private prevUser?: UserInfo;
50-
private prevSegments?: string[] | null;
50+
private prevSegments?: string[];
5151

5252
constructor(config: UserContextManagerConfig) {
5353
this.client = config.client;
@@ -66,7 +66,7 @@ export class UserContextManager {
6666
* @param qualifiedSegments - Optional pre-fetched segments. When provided,
6767
* @param skipSegments - Whether to skip ODP segment fetching (default: false)
6868
*/
69-
resolveUserContext(user?: UserInfo, qualifiedSegments?: string[] | null, skipSegments = false): void {
69+
resolveUserContext(user?: UserInfo, qualifiedSegments?: string[], skipSegments = false): void {
7070
if (
7171
this.initialized &&
7272
this.skipSegments === skipSegments &&
@@ -96,11 +96,7 @@ export class UserContextManager {
9696
this.disposed = true;
9797
}
9898

99-
private async createUserContext(
100-
requestId: number,
101-
user?: UserInfo,
102-
qualifiedSegments?: string[] | null
103-
): Promise<void> {
99+
private async createUserContext(requestId: number, user?: UserInfo, qualifiedSegments?: string[]): Promise<void> {
104100
if (!user?.id && this.meta.hasVuidManager) {
105101
await this.client.onReady();
106102
if (this.isStale(requestId)) return;
@@ -122,7 +118,7 @@ export class UserContextManager {
122118
if (this.isStale(requestId)) return;
123119

124120
if (this.client.isOdpIntegrated()) {
125-
const snapshot = qualifiedSegments ? [...qualifiedSegments] : null;
121+
const snapshot = [...qualifiedSegments];
126122

127123
await ctx.fetchQualifiedSegments();
128124

src/utils/helpers.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,13 @@
1515
*/
1616

1717
import type { UserInfo } from '../provider/types';
18-
import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk';
19-
20-
/**
21-
* Creates a default decision to return while loading or when an error occurs.
22-
*/
23-
export function createDefaultDecision(flagKey: string): OptimizelyDecision {
24-
return {
25-
variationKey: null,
26-
enabled: false,
27-
variables: {},
28-
ruleKey: null,
29-
flagKey,
30-
userContext: { id: null, attributes: {} } as unknown as OptimizelyUserContext,
31-
reasons: ['Optimizely SDK not configured properly yet.'],
32-
};
33-
}
3418

3519
/**
3620
* Compares two string arrays for value equality (order-insensitive).
3721
* Used to prevent redundant user context creation when the segments prop
3822
* is referentially different but value-equal.
3923
*/
40-
export function areSegmentsEqual(a?: string[] | null, b?: string[] | null): boolean {
24+
export function areSegmentsEqual(a?: string[], b?: string[]): boolean {
4125
if (a === b) return true;
4226
if (!a || !b) return false;
4327
if (a.length !== b.length) return false;

0 commit comments

Comments
 (0)