Skip to content

Commit c0dd706

Browse files
[FSSDK-12274] provider user context manager integration
1 parent 3bd5658 commit c0dd706

8 files changed

Lines changed: 528 additions & 20 deletions

File tree

src/provider/OptimizelyProvider.spec.tsx

Lines changed: 400 additions & 4 deletions
Large diffs are not rendered by default.

src/provider/OptimizelyProvider.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import React, { createContext, useRef, useMemo, useEffect } from 'react';
1818

1919
import { ProviderStateStore } from './ProviderStateStore';
20-
import type { OptimizelyProviderProps, OptimizelyContextValue } from './types';
20+
import { UserContextManager } from '../utils/UserContextManager';
21+
import { areUsersEqual, areSegmentsEqual } from '../utils/helpers';
22+
import type { OptimizelyProviderProps, OptimizelyContextValue, UserInfo } from './types';
2123

2224
// TODO: Replace with proper logger when implemented
2325
const logger = {
@@ -36,10 +38,13 @@ export function OptimizelyProvider({
3638
user,
3739
timeout,
3840
skipSegments = false,
41+
qualifiedSegments,
3942
children,
4043
}: OptimizelyProviderProps): React.ReactElement {
4144
const storeRef = useRef<ProviderStateStore | null>(null);
42-
// Todo: const prevUserRef = useRef<UserInfo | undefined>(undefined);
45+
const managerRef = useRef<UserContextManager | null>(null);
46+
const prevUserRef = useRef<UserInfo | undefined>(undefined);
47+
const prevSegmentsRef = useRef<string[] | undefined>(undefined);
4348

4449
if (storeRef.current === null) {
4550
storeRef.current = new ProviderStateStore();
@@ -52,7 +57,7 @@ export function OptimizelyProvider({
5257
store,
5358
client,
5459
}),
55-
[store, client]
60+
[client, store]
5661
);
5762

5863
useEffect(() => {
@@ -88,10 +93,48 @@ export function OptimizelyProvider({
8893
};
8994
}, [client, timeout, store]);
9095

91-
// Handle user changes
96+
// Effect 2: Manager lifecycle (create/dispose when client/skipSegments changes)
97+
// Does NOT trigger createUserContext — only manages the manager instance
9298
useEffect(() => {
93-
// TODO: UserContextManager implementation
94-
}, []);
99+
if (!client) return;
100+
101+
managerRef.current?.dispose();
102+
managerRef.current = new UserContextManager({
103+
client,
104+
skipSegments,
105+
onUserContextReady: (ctx) => store.setUserContext(ctx),
106+
onError: (error) => store.setError(error),
107+
});
108+
109+
// Reset prevUser/segments so Effect 3 treats current user as new
110+
prevUserRef.current = undefined;
111+
prevSegmentsRef.current = undefined;
112+
113+
return () => {
114+
managerRef.current?.dispose();
115+
managerRef.current = null;
116+
};
117+
}, [client, skipSegments, store]);
118+
119+
// Effect 3: User/segments prop changes — sole trigger for createUserContext
120+
// Runs on mount (prevUser is undefined) and on user/qualifiedSegments prop changes.
121+
// Also re-runs when client/skipSegments change (because Effect 2 resets
122+
// prevUserRef/prevSegmentsRef to undefined, and these deps are shared).
123+
useEffect(() => {
124+
if (!managerRef.current) return;
125+
126+
const prevUser = prevUserRef.current;
127+
const prevSegments = prevSegmentsRef.current;
128+
const userChanged = prevUser === undefined || !areUsersEqual(prevUser, user);
129+
const segmentsChanged = !areSegmentsEqual(prevSegments, qualifiedSegments);
130+
131+
if (!userChanged && !segmentsChanged) return;
132+
133+
prevUserRef.current = user;
134+
prevSegmentsRef.current = qualifiedSegments;
135+
managerRef.current.createUserContext(user, qualifiedSegments);
136+
// eslint-disable-next-line react-hooks/exhaustive-deps
137+
}, [user?.id, user?.attributes, qualifiedSegments, client, skipSegments]);
95138

96139
// Cleanup on unmount
97140
useEffect(() => {

src/provider/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export interface OptimizelyProviderProps {
5252
*/
5353
skipSegments?: boolean;
5454

55+
/**
56+
* Pre-fetched qualified segments for the user.
57+
* When provided, the user context is created immediately with these segments,
58+
* and a background fetch verifies them (unless skipSegments is true).
59+
* `undefined` = normal flow, `[]` = explicit "zero segments".
60+
*/
61+
qualifiedSegments?: string[];
62+
5563
/**
5664
* React children to render.
5765
*/

src/utils/UserContextManager.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { Client, OptimizelyUserContext } from '@optimizely/optimizely-sdk';
1818
import { REACT_CLIENT_META } from '../client/index';
1919
import type { ReactClientMeta } from '../client/index';
2020
import type { UserInfo } from '../provider/index';
21+
import { areSegmentsEqual } from './helpers';
2122

2223
export interface UserContextManagerConfig {
2324
client: Client;
@@ -104,7 +105,7 @@ export class UserContextManager {
104105
if (this.isStale(requestId)) return;
105106

106107
// update only if different
107-
if (!this.segmentsEqual(snapshot, ctx.qualifiedSegments)) {
108+
if (!areSegmentsEqual(snapshot, ctx.qualifiedSegments)) {
108109
this.onUserContextReady(ctx);
109110
}
110111
}
@@ -126,15 +127,6 @@ export class UserContextManager {
126127
this.onUserContextReady(ctx);
127128
}
128129

129-
private segmentsEqual(a: string[] | null, b: string[] | null): boolean {
130-
if (a === b) return true;
131-
if (!a || !b) return false;
132-
if (a.length !== b.length) return false;
133-
const sortedA = [...a].sort();
134-
const sortedB = [...b].sort();
135-
return sortedA.every((val, i) => val === sortedB[i]);
136-
}
137-
138130
private isStale(requestId: number): boolean {
139131
return this.disposed || requestId !== this.requestId;
140132
}

src/utils/helpers.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { UserInfo } from '../provider/types';
18+
19+
/**
20+
* Compares two string arrays for value equality (order-insensitive).
21+
* Used to prevent redundant user context creation when the segments prop
22+
* is referentially different but value-equal.
23+
*/
24+
export function areSegmentsEqual(a?: string[] | null, b?: string[] | null): boolean {
25+
if (a === b) return true;
26+
if (!a || !b) return false;
27+
if (a.length !== b.length) return false;
28+
const sortedA = [...a].sort();
29+
const sortedB = [...b].sort();
30+
return sortedA.every((val, i) => val === sortedB[i]);
31+
}
32+
33+
/**
34+
* Compares two UserInfo objects for value equality.
35+
* Used to prevent redundant user context creation when the user prop
36+
* is referentially different but value-equal.
37+
*/
38+
export function areUsersEqual(user1?: UserInfo, user2?: UserInfo): boolean {
39+
if (user1 === user2) return true;
40+
if (!user1 || !user2) return false;
41+
if (user1.id !== user2.id) return false;
42+
43+
const attrs1 = user1.attributes || {};
44+
const attrs2 = user2.attributes || {};
45+
46+
const keys1 = Object.keys(attrs1);
47+
const keys2 = Object.keys(attrs2);
48+
49+
if (keys1.length !== keys2.length) return false;
50+
51+
for (const key of keys1) {
52+
if (attrs1[key] !== attrs2[key]) return false;
53+
}
54+
55+
return true;
56+
}

src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@
1616

1717
export { UserContextManager } from './UserContextManager';
1818
export type { UserContextManagerConfig } from './UserContextManager';
19+
20+
export { areUsersEqual } from './helpers';

tsconfig.spec.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["./src/**/*.ts", "./src/**/*.tsx", "**/*.spec.ts", "vitest.setup.ts"],
4+
"exclude": ["./dist", "./.build", "./node_modules"],
5+
"compilerOptions": {
6+
"types": ["vitest/globals", "vitest/jsdom"]
7+
}
8+
}

vitest.config.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ export default defineConfig({
44
test: {
55
environment: 'happy-dom',
66
setupFiles: ['./vitest.setup.ts'],
7+
typecheck: {
8+
tsconfig: './tsconfig.spec.json',
9+
},
710
include: [
811
'src/client/**/*.spec.{ts,tsx}',
912
'src/provider/**/*.spec.{ts,tsx}',

0 commit comments

Comments
 (0)