Skip to content

Commit 6b3a100

Browse files
authored
feat: support static client component rendering (#1227)
To support static client rendering, we will need to introduce a proper noop client for the client sdk since that will be ran on server side during build time. <!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Changes `createClient` behavior when `window` is undefined and alters server-side initialization/error semantics, which could affect SSR/static rendering and early flag reads. Risk is moderate due to potential subtle differences in hydration-time behavior and expected initialization state. > > **Overview** > Enables static/SSR rendering of React client components by having `createClient` return a dedicated `createNoopClient` stub when `window` is undefined, avoiding runtime errors during build-time rendering. > > Adds `createNoopClient(bootstrap?)`, which can *ad-hoc* return flag values from provided bootstrap data (excluding `$` metadata), reports initialization as `complete` only when bootstrap is provided, and otherwise stays `initializing` with no initialization error. Updates and expands tests to cover the new noop client behavior and bootstrap-based variation results. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2998425. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1227" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent 6a4c42f commit 6b3a100

4 files changed

Lines changed: 387 additions & 76 deletions

File tree

packages/sdk/react/__tests__/client/LDReactClient.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,14 @@ it('getInitializationError() returns the error after a failed start()', async ()
322322
expect(client.getInitializationError()).toBe(failError);
323323
});
324324

325-
it('noop client getInitializationError() returns an error', () => {
325+
it('noop client returns initializing state on the server without bootstrap', () => {
326326
const originalWindow = global.window;
327327
// @ts-ignore
328328
delete global.window;
329329

330330
const client = createClient('test-id', { kind: 'user', key: 'u1' });
331-
expect(client.getInitializationError()).toEqual(
332-
new Error('Server-side client cannot be used to evaluate flags'),
333-
);
331+
expect(client.getInitializationState()).toBe('initializing');
332+
expect(client.getInitializationError()).toBeUndefined();
334333

335334
// @ts-ignore
336335
global.window = originalWindow;
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { createNoopClient } from '../../src/client/createNoopClient';
2+
3+
// Ensure we're in an SSR-like environment (no window).
4+
const originalWindow = global.window;
5+
beforeAll(() => {
6+
// @ts-ignore
7+
delete global.window;
8+
});
9+
afterAll(() => {
10+
// @ts-ignore
11+
global.window = originalWindow;
12+
});
13+
14+
const bootstrapData = {
15+
'bool-flag': true,
16+
'number-flag': 42,
17+
'string-flag': 'hello',
18+
'json-flag': { nested: true },
19+
'null-flag': null,
20+
$flagsState: {
21+
'bool-flag': { variation: 0, version: 5 },
22+
'number-flag': { variation: 1, version: 3 },
23+
'string-flag': { variation: 2, version: 7 },
24+
'json-flag': { variation: 0, version: 1 },
25+
'null-flag': { variation: 1, version: 2 },
26+
},
27+
$valid: true,
28+
};
29+
30+
describe('given bootstrap data', () => {
31+
const client = createNoopClient(bootstrapData);
32+
33+
it('returns boolean value when key exists and type matches', () => {
34+
expect(client.boolVariation('bool-flag', false)).toBe(true);
35+
});
36+
37+
it('returns default when boolean key exists but type mismatches', () => {
38+
expect(client.boolVariation('string-flag', false)).toBe(false);
39+
});
40+
41+
it('returns default when boolean key is missing', () => {
42+
expect(client.boolVariation('missing', true)).toBe(true);
43+
});
44+
45+
it('returns number value when key exists and type matches', () => {
46+
expect(client.numberVariation('number-flag', 0)).toBe(42);
47+
});
48+
49+
it('returns default when number key exists but type mismatches', () => {
50+
expect(client.numberVariation('bool-flag', 99)).toBe(99);
51+
});
52+
53+
it('returns default when number key is missing', () => {
54+
expect(client.numberVariation('missing', 7)).toBe(7);
55+
});
56+
57+
it('returns string value when key exists and type matches', () => {
58+
expect(client.stringVariation('string-flag', 'fallback')).toBe('hello');
59+
});
60+
61+
it('returns default when string key exists but type mismatches', () => {
62+
expect(client.stringVariation('number-flag', 'fallback')).toBe('fallback');
63+
});
64+
65+
it('returns default when string key is missing', () => {
66+
expect(client.stringVariation('missing', 'fallback')).toBe('fallback');
67+
});
68+
69+
it('returns json value when key exists', () => {
70+
expect(client.jsonVariation('json-flag', null)).toEqual({ nested: true });
71+
});
72+
73+
it('returns json value even for null', () => {
74+
expect(client.jsonVariation('null-flag', 'default')).toBeNull();
75+
});
76+
77+
it('returns default when json key is missing', () => {
78+
expect(client.jsonVariation('missing', 'default')).toBe('default');
79+
});
80+
});
81+
82+
describe('detail variants return null variationIndex and reason', () => {
83+
const client = createNoopClient(bootstrapData);
84+
85+
it('returns value with null variationIndex and reason for boolVariationDetail', () => {
86+
const detail = client.boolVariationDetail('bool-flag', false);
87+
expect(detail.value).toBe(true);
88+
expect(detail.variationIndex).toBeNull();
89+
expect(detail.reason).toBeNull();
90+
});
91+
92+
it('returns value with null variationIndex and reason for numberVariationDetail', () => {
93+
const detail = client.numberVariationDetail('number-flag', 0);
94+
expect(detail.value).toBe(42);
95+
expect(detail.variationIndex).toBeNull();
96+
expect(detail.reason).toBeNull();
97+
});
98+
99+
it('returns value with null variationIndex and reason for stringVariationDetail', () => {
100+
const detail = client.stringVariationDetail('string-flag', 'fallback');
101+
expect(detail.value).toBe('hello');
102+
expect(detail.variationIndex).toBeNull();
103+
expect(detail.reason).toBeNull();
104+
});
105+
106+
it('returns value with null variationIndex and reason for jsonVariationDetail', () => {
107+
const detail = client.jsonVariationDetail('json-flag', null);
108+
expect(detail.value).toEqual({ nested: true });
109+
expect(detail.variationIndex).toBeNull();
110+
expect(detail.reason).toBeNull();
111+
});
112+
113+
it('returns default value with null variationIndex for missing key', () => {
114+
const detail = client.boolVariationDetail('missing', false);
115+
expect(detail.value).toBe(false);
116+
expect(detail.variationIndex).toBeNull();
117+
expect(detail.reason).toBeNull();
118+
});
119+
120+
it('returns default value in detail when type mismatches', () => {
121+
const detail = client.boolVariationDetail('string-flag', false);
122+
expect(detail.value).toBe(false);
123+
expect(detail.variationIndex).toBeNull();
124+
expect(detail.reason).toBeNull();
125+
});
126+
});
127+
128+
describe('allFlags returns non-$ keys only', () => {
129+
const client = createNoopClient(bootstrapData);
130+
131+
it('excludes $flagsState and $valid', () => {
132+
const flags = client.allFlags();
133+
expect(flags).toEqual({
134+
'bool-flag': true,
135+
'number-flag': 42,
136+
'string-flag': 'hello',
137+
'json-flag': { nested: true },
138+
'null-flag': null,
139+
});
140+
});
141+
142+
it('returns a copy, not a reference', () => {
143+
const flags1 = client.allFlags();
144+
const flags2 = client.allFlags();
145+
expect(flags1).not.toBe(flags2);
146+
});
147+
});
148+
149+
describe('stubbed LDClient methods', () => {
150+
const client = createNoopClient(bootstrapData);
151+
152+
it('close resolves immediately', async () => {
153+
await expect(client.close()).resolves.toBeUndefined();
154+
});
155+
156+
it('flush resolves immediately', async () => {
157+
await expect(client.flush()).resolves.toBeUndefined();
158+
});
159+
160+
it('identify resolves immediately', async () => {
161+
await expect(client.identify({ kind: 'user', key: 'test' })).resolves.toBeUndefined();
162+
});
163+
164+
it('track does not throw', () => {
165+
expect(() => client.track('event-key', undefined)).not.toThrow();
166+
});
167+
168+
it('variation returns value from bootstrap', () => {
169+
expect(client.variation('bool-flag', false)).toBe(true);
170+
});
171+
172+
it('variation returns default for missing key', () => {
173+
expect(client.variation('missing', 'default')).toBe('default');
174+
});
175+
176+
it('variationDetail returns value with null variationIndex and reason', () => {
177+
const detail = client.variationDetail('bool-flag', false);
178+
expect(detail.value).toBe(true);
179+
expect(detail.variationIndex).toBeNull();
180+
expect(detail.reason).toBeNull();
181+
});
182+
183+
it('waitForInitialization resolves immediately', async () => {
184+
await expect(client.waitForInitialization()).resolves.toBeUndefined();
185+
});
186+
});
187+
188+
describe('handles edge cases gracefully', () => {
189+
it('handles undefined bootstrap', () => {
190+
const client = createNoopClient(undefined);
191+
expect(client.stringVariation('any', 'def')).toBe('def');
192+
expect(client.allFlags()).toEqual({});
193+
});
194+
195+
it('handles no bootstrap argument', () => {
196+
const client = createNoopClient();
197+
expect(client.stringVariation('any', 'def')).toBe('def');
198+
expect(client.allFlags()).toEqual({});
199+
});
200+
201+
it('handles bootstrap missing $flagsState', () => {
202+
const client = createNoopClient({ 'my-flag': true });
203+
expect(client.boolVariation('my-flag', false)).toBe(true);
204+
205+
const detail = client.boolVariationDetail('my-flag', false);
206+
expect(detail.value).toBe(true);
207+
expect(detail.variationIndex).toBeNull();
208+
});
209+
210+
it('reports initialization state as complete when bootstrap is provided', () => {
211+
const client = createNoopClient({});
212+
expect(client.getInitializationState()).toBe('complete');
213+
});
214+
215+
it('reports initialization state as complete when bootstrap has flags', () => {
216+
const client = createNoopClient({ 'my-flag': true });
217+
expect(client.getInitializationState()).toBe('complete');
218+
});
219+
220+
it('reports initialization state as initializing when bootstrap is not provided', () => {
221+
const client = createNoopClient();
222+
expect(client.getInitializationState()).toBe('initializing');
223+
});
224+
225+
it('isReady returns true when bootstrap is provided', () => {
226+
const client = createNoopClient({});
227+
expect(client.isReady()).toBe(true);
228+
});
229+
230+
it('isReady returns false when bootstrap is not provided', () => {
231+
const client = createNoopClient();
232+
expect(client.isReady()).toBe(false);
233+
});
234+
});
235+
236+
describe('event and lifecycle stubs do not throw and return expected values', () => {
237+
const client = createNoopClient(bootstrapData);
238+
239+
it('getContext returns undefined', () => {
240+
expect(client.getContext()).toBeUndefined();
241+
});
242+
243+
it('shouldUseCamelCaseFlagKeys returns true', () => {
244+
expect(client.shouldUseCamelCaseFlagKeys()).toBe(true);
245+
});
246+
247+
it('on does not throw', () => {
248+
expect(() => client.on('change:bool-flag', () => {})).not.toThrow();
249+
});
250+
251+
it('off does not throw', () => {
252+
expect(() => client.off('change:bool-flag', () => {})).not.toThrow();
253+
});
254+
255+
it('onContextChange returns a callable unsubscribe function', () => {
256+
const unsubscribe = client.onContextChange(() => {});
257+
expect(typeof unsubscribe).toBe('function');
258+
expect(() => unsubscribe()).not.toThrow();
259+
});
260+
261+
it('onInitializationStatusChange returns a callable unsubscribe function', () => {
262+
const unsubscribe = client.onInitializationStatusChange(() => {});
263+
expect(typeof unsubscribe).toBe('function');
264+
expect(() => unsubscribe()).not.toThrow();
265+
});
266+
267+
it('start does not throw', () => {
268+
expect(() => client.start()).not.toThrow();
269+
});
270+
271+
it('addHook does not throw', () => {
272+
expect(() => client.addHook({} as any)).not.toThrow();
273+
});
274+
275+
it('setStreaming does not throw', () => {
276+
expect(() => client.setStreaming(true)).not.toThrow();
277+
});
278+
});

packages/sdk/react/src/client/LDReactClient.tsx

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,86 +2,17 @@ import {
22
createClient as createBaseClient,
33
LDContext,
44
LDContextStrict,
5-
type LDEvaluationDetailTyped,
6-
LDEvaluationReason,
7-
type LDFlagValue,
85
LDIdentifyOptions,
96
LDIdentifyResult,
107
LDOptions,
118
LDStartOptions,
129
LDWaitForInitializationResult,
1310
} from '@launchdarkly/js-client-sdk';
1411

12+
import { createNoopClient } from './createNoopClient';
1513
import { InitializedState, LDReactClient } from './LDClient';
1614
import { LDReactClientOptions } from './LDOptions';
1715

18-
function isServerSide() {
19-
return typeof window === 'undefined';
20-
}
21-
22-
function noopDetail<T>(defaultValue: T): { value: T; kind: LDEvaluationReason['kind'] } {
23-
return { value: defaultValue, kind: 'NO Evaluation Reason' };
24-
}
25-
26-
/**
27-
* @privateRemarks
28-
* **WARNING:** This function is going to be removed soon! sdk-2043
29-
*/
30-
function createNoopReactClient(): LDReactClient {
31-
return {
32-
allFlags: () => ({}),
33-
boolVariation: (_key: string, defaultValue: boolean) => defaultValue,
34-
boolVariationDetail: (key: string, defaultValue: boolean) =>
35-
noopDetail(defaultValue) as LDEvaluationDetailTyped<boolean>,
36-
close: () => Promise.resolve(),
37-
flush: () => Promise.resolve({ result: true }),
38-
getContext: () => undefined,
39-
getInitializationState: (): InitializedState => 'failed',
40-
getInitializationError: (): Error | undefined =>
41-
new Error('Server-side client cannot be used to evaluate flags'),
42-
identify: () => Promise.resolve({ status: 'completed' as const }),
43-
jsonVariation: (_key: string, defaultValue: unknown) => defaultValue,
44-
jsonVariationDetail: (key: string, defaultValue: unknown) =>
45-
noopDetail(defaultValue) as LDEvaluationDetailTyped<unknown>,
46-
logger: {
47-
debug: () => {},
48-
info: () => {},
49-
warn: () => {},
50-
error: () => {},
51-
},
52-
numberVariation: (_key: string, defaultValue: number) => defaultValue,
53-
numberVariationDetail: (key: string, defaultValue: number) =>
54-
noopDetail(defaultValue) as LDEvaluationDetailTyped<number>,
55-
off: () => {},
56-
on: () => {},
57-
onContextChange: () => () => {},
58-
onInitializationStatusChange: () => () => {},
59-
setStreaming: () => {},
60-
start: () =>
61-
Promise.resolve({
62-
status: 'failed' as const,
63-
error: new Error('Server-side client cannot be used to start'),
64-
}),
65-
stringVariation: (_key: string, defaultValue: string) => defaultValue,
66-
stringVariationDetail: (key: string, defaultValue: string) =>
67-
noopDetail(defaultValue) as LDEvaluationDetailTyped<string>,
68-
track: () => {},
69-
variation: (_key: string, defaultValue?: LDFlagValue) => defaultValue ?? null,
70-
variationDetail: (key: string, defaultValue?: LDFlagValue) => {
71-
const def = defaultValue ?? null;
72-
return noopDetail(def) as LDEvaluationDetailTyped<LDFlagValue>;
73-
},
74-
waitForInitialization: () =>
75-
Promise.resolve({
76-
status: 'failed' as const,
77-
error: new Error('Server-side client cannot be used to wait for initialization'),
78-
}),
79-
addHook: () => {},
80-
isReady: () => true,
81-
shouldUseCamelCaseFlagKeys: () => true,
82-
};
83-
}
84-
8516
/**
8617
* Creates a new instance of the LaunchDarkly client for React.
8718
*
@@ -114,8 +45,11 @@ export function createClient(
11445
context: LDContext,
11546
options: LDReactClientOptions = {},
11647
): LDReactClient {
117-
if (isServerSide()) {
118-
return createNoopReactClient();
48+
// This should not happen during runtime, but some frameworks such as Next.js supports
49+
// static rendering which will attempt to render client code during build time. In these cases,
50+
// we will need to use the noop client to avoid errors.
51+
if (typeof window === 'undefined') {
52+
return createNoopClient();
11953
}
12054

12155
const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options;

0 commit comments

Comments
 (0)