Skip to content
Merged
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
7 changes: 3 additions & 4 deletions packages/sdk/react/__tests__/client/LDReactClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,15 +322,14 @@ it('getInitializationError() returns the error after a failed start()', async ()
expect(client.getInitializationError()).toBe(failError);
});

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

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

// @ts-ignore
global.window = originalWindow;
Expand Down
278 changes: 278 additions & 0 deletions packages/sdk/react/__tests__/client/createNoopClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { createNoopClient } from '../../src/client/createNoopClient';

// Ensure we're in an SSR-like environment (no window).
const originalWindow = global.window;
beforeAll(() => {
// @ts-ignore
delete global.window;
});
afterAll(() => {
// @ts-ignore
global.window = originalWindow;
});

const bootstrapData = {
'bool-flag': true,
'number-flag': 42,
'string-flag': 'hello',
'json-flag': { nested: true },
'null-flag': null,
$flagsState: {
'bool-flag': { variation: 0, version: 5 },
'number-flag': { variation: 1, version: 3 },
'string-flag': { variation: 2, version: 7 },
'json-flag': { variation: 0, version: 1 },
'null-flag': { variation: 1, version: 2 },
},
$valid: true,
};

describe('given bootstrap data', () => {
const client = createNoopClient(bootstrapData);

it('returns boolean value when key exists and type matches', () => {
expect(client.boolVariation('bool-flag', false)).toBe(true);
});

it('returns default when boolean key exists but type mismatches', () => {
expect(client.boolVariation('string-flag', false)).toBe(false);
});

it('returns default when boolean key is missing', () => {
expect(client.boolVariation('missing', true)).toBe(true);
});

it('returns number value when key exists and type matches', () => {
expect(client.numberVariation('number-flag', 0)).toBe(42);
});

it('returns default when number key exists but type mismatches', () => {
expect(client.numberVariation('bool-flag', 99)).toBe(99);
});

it('returns default when number key is missing', () => {
expect(client.numberVariation('missing', 7)).toBe(7);
});

it('returns string value when key exists and type matches', () => {
expect(client.stringVariation('string-flag', 'fallback')).toBe('hello');
});

it('returns default when string key exists but type mismatches', () => {
expect(client.stringVariation('number-flag', 'fallback')).toBe('fallback');
});

it('returns default when string key is missing', () => {
expect(client.stringVariation('missing', 'fallback')).toBe('fallback');
});

it('returns json value when key exists', () => {
expect(client.jsonVariation('json-flag', null)).toEqual({ nested: true });
});

it('returns json value even for null', () => {
expect(client.jsonVariation('null-flag', 'default')).toBeNull();
});

it('returns default when json key is missing', () => {
expect(client.jsonVariation('missing', 'default')).toBe('default');
});
});

describe('detail variants return null variationIndex and reason', () => {
const client = createNoopClient(bootstrapData);

it('returns value with null variationIndex and reason for boolVariationDetail', () => {
const detail = client.boolVariationDetail('bool-flag', false);
expect(detail.value).toBe(true);
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('returns value with null variationIndex and reason for numberVariationDetail', () => {
const detail = client.numberVariationDetail('number-flag', 0);
expect(detail.value).toBe(42);
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('returns value with null variationIndex and reason for stringVariationDetail', () => {
const detail = client.stringVariationDetail('string-flag', 'fallback');
expect(detail.value).toBe('hello');
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('returns value with null variationIndex and reason for jsonVariationDetail', () => {
const detail = client.jsonVariationDetail('json-flag', null);
expect(detail.value).toEqual({ nested: true });
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('returns default value with null variationIndex for missing key', () => {
const detail = client.boolVariationDetail('missing', false);
expect(detail.value).toBe(false);
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('returns default value in detail when type mismatches', () => {
const detail = client.boolVariationDetail('string-flag', false);
expect(detail.value).toBe(false);
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});
});

describe('allFlags returns non-$ keys only', () => {
const client = createNoopClient(bootstrapData);

it('excludes $flagsState and $valid', () => {
const flags = client.allFlags();
expect(flags).toEqual({
'bool-flag': true,
'number-flag': 42,
'string-flag': 'hello',
'json-flag': { nested: true },
'null-flag': null,
});
});

it('returns a copy, not a reference', () => {
const flags1 = client.allFlags();
const flags2 = client.allFlags();
expect(flags1).not.toBe(flags2);
});
});

describe('stubbed LDClient methods', () => {
const client = createNoopClient(bootstrapData);

it('close resolves immediately', async () => {
await expect(client.close()).resolves.toBeUndefined();
});

it('flush resolves immediately', async () => {
await expect(client.flush()).resolves.toBeUndefined();
});

it('identify resolves immediately', async () => {
await expect(client.identify({ kind: 'user', key: 'test' })).resolves.toBeUndefined();
});

it('track does not throw', () => {
expect(() => client.track('event-key', undefined)).not.toThrow();
});

it('variation returns value from bootstrap', () => {
expect(client.variation('bool-flag', false)).toBe(true);
});

it('variation returns default for missing key', () => {
expect(client.variation('missing', 'default')).toBe('default');
});

it('variationDetail returns value with null variationIndex and reason', () => {
const detail = client.variationDetail('bool-flag', false);
expect(detail.value).toBe(true);
expect(detail.variationIndex).toBeNull();
expect(detail.reason).toBeNull();
});

it('waitForInitialization resolves immediately', async () => {
await expect(client.waitForInitialization()).resolves.toBeUndefined();
});
});

describe('handles edge cases gracefully', () => {
it('handles undefined bootstrap', () => {
const client = createNoopClient(undefined);
expect(client.stringVariation('any', 'def')).toBe('def');
expect(client.allFlags()).toEqual({});
});

it('handles no bootstrap argument', () => {
const client = createNoopClient();
expect(client.stringVariation('any', 'def')).toBe('def');
expect(client.allFlags()).toEqual({});
});

it('handles bootstrap missing $flagsState', () => {
const client = createNoopClient({ 'my-flag': true });
expect(client.boolVariation('my-flag', false)).toBe(true);

const detail = client.boolVariationDetail('my-flag', false);
expect(detail.value).toBe(true);
expect(detail.variationIndex).toBeNull();
});

it('reports initialization state as complete when bootstrap is provided', () => {
const client = createNoopClient({});
expect(client.getInitializationState()).toBe('complete');
});

it('reports initialization state as complete when bootstrap has flags', () => {
const client = createNoopClient({ 'my-flag': true });
expect(client.getInitializationState()).toBe('complete');
});

it('reports initialization state as initializing when bootstrap is not provided', () => {
const client = createNoopClient();
expect(client.getInitializationState()).toBe('initializing');
});

it('isReady returns true when bootstrap is provided', () => {
const client = createNoopClient({});
expect(client.isReady()).toBe(true);
});

it('isReady returns false when bootstrap is not provided', () => {
const client = createNoopClient();
expect(client.isReady()).toBe(false);
});
});

describe('event and lifecycle stubs do not throw and return expected values', () => {
const client = createNoopClient(bootstrapData);

it('getContext returns undefined', () => {
expect(client.getContext()).toBeUndefined();
});

it('shouldUseCamelCaseFlagKeys returns true', () => {
expect(client.shouldUseCamelCaseFlagKeys()).toBe(true);
});

it('on does not throw', () => {
expect(() => client.on('change:bool-flag', () => {})).not.toThrow();
});

it('off does not throw', () => {
expect(() => client.off('change:bool-flag', () => {})).not.toThrow();
});

it('onContextChange returns a callable unsubscribe function', () => {
const unsubscribe = client.onContextChange(() => {});
expect(typeof unsubscribe).toBe('function');
expect(() => unsubscribe()).not.toThrow();
});

it('onInitializationStatusChange returns a callable unsubscribe function', () => {
const unsubscribe = client.onInitializationStatusChange(() => {});
expect(typeof unsubscribe).toBe('function');
expect(() => unsubscribe()).not.toThrow();
});

it('start does not throw', () => {
expect(() => client.start()).not.toThrow();
});

it('addHook does not throw', () => {
expect(() => client.addHook({} as any)).not.toThrow();
});

it('setStreaming does not throw', () => {
expect(() => client.setStreaming(true)).not.toThrow();
});
});
78 changes: 6 additions & 72 deletions packages/sdk/react/src/client/LDReactClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,86 +2,17 @@ import {
createClient as createBaseClient,
LDContext,
LDContextStrict,
type LDEvaluationDetailTyped,
LDEvaluationReason,
type LDFlagValue,
LDIdentifyOptions,
LDIdentifyResult,
LDOptions,
LDStartOptions,
LDWaitForInitializationResult,
} from '@launchdarkly/js-client-sdk';

import { createNoopClient } from './createNoopClient';
import { InitializedState, LDReactClient } from './LDClient';
import { LDReactClientOptions } from './LDOptions';

function isServerSide() {
return typeof window === 'undefined';
}

function noopDetail<T>(defaultValue: T): { value: T; kind: LDEvaluationReason['kind'] } {
return { value: defaultValue, kind: 'NO Evaluation Reason' };
}

/**
* @privateRemarks
* **WARNING:** This function is going to be removed soon! sdk-2043
*/
function createNoopReactClient(): LDReactClient {
return {
allFlags: () => ({}),
boolVariation: (_key: string, defaultValue: boolean) => defaultValue,
boolVariationDetail: (key: string, defaultValue: boolean) =>
noopDetail(defaultValue) as LDEvaluationDetailTyped<boolean>,
close: () => Promise.resolve(),
flush: () => Promise.resolve({ result: true }),
getContext: () => undefined,
getInitializationState: (): InitializedState => 'failed',
getInitializationError: (): Error | undefined =>
new Error('Server-side client cannot be used to evaluate flags'),
identify: () => Promise.resolve({ status: 'completed' as const }),
jsonVariation: (_key: string, defaultValue: unknown) => defaultValue,
jsonVariationDetail: (key: string, defaultValue: unknown) =>
noopDetail(defaultValue) as LDEvaluationDetailTyped<unknown>,
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
numberVariation: (_key: string, defaultValue: number) => defaultValue,
numberVariationDetail: (key: string, defaultValue: number) =>
noopDetail(defaultValue) as LDEvaluationDetailTyped<number>,
off: () => {},
on: () => {},
onContextChange: () => () => {},
onInitializationStatusChange: () => () => {},
setStreaming: () => {},
start: () =>
Promise.resolve({
status: 'failed' as const,
error: new Error('Server-side client cannot be used to start'),
}),
stringVariation: (_key: string, defaultValue: string) => defaultValue,
stringVariationDetail: (key: string, defaultValue: string) =>
noopDetail(defaultValue) as LDEvaluationDetailTyped<string>,
track: () => {},
variation: (_key: string, defaultValue?: LDFlagValue) => defaultValue ?? null,
variationDetail: (key: string, defaultValue?: LDFlagValue) => {
const def = defaultValue ?? null;
return noopDetail(def) as LDEvaluationDetailTyped<LDFlagValue>;
},
waitForInitialization: () =>
Promise.resolve({
status: 'failed' as const,
error: new Error('Server-side client cannot be used to wait for initialization'),
}),
addHook: () => {},
isReady: () => true,
shouldUseCamelCaseFlagKeys: () => true,
};
}

/**
* Creates a new instance of the LaunchDarkly client for React.
*
Expand Down Expand Up @@ -114,8 +45,11 @@ export function createClient(
context: LDContext,
options: LDReactClientOptions = {},
): LDReactClient {
if (isServerSide()) {
return createNoopReactClient();
// This should not happen during runtime, but some frameworks such as Next.js supports
// static rendering which will attempt to render client code during build time. In these cases,
// we will need to use the noop client to avoid errors.
if (typeof window === 'undefined') {
return createNoopClient();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bootstrap data is never passed to createNoopClient during SSR

When running server-side (e.g., Next.js SSR/static rendering), createClient at LDReactClient.tsx:52 calls createNoopClient() with no arguments, discarding any bootstrap data the user provided. The bootstrap data flows through createLDReactProvider (LDReactProvider.tsx:123-129) into client.start({ bootstrap }), but the noop client's start is a no-op (() => Promise.resolve()) that ignores its arguments.

As a result, during SSR:

  • isReady() returns false instead of true
  • getInitializationState() returns 'initializing' instead of 'complete'
  • All variation hooks (via useVariationCore.ts:14,27-30) see ready === false and return default values instead of bootstrap flag values

createNoopClient was clearly designed to support bootstrap (it accepts a bootstrap parameter, extracts flags, sets hasBootstrap, and conditions isReady/getInitializationState on it), but no code path ever supplies it.

SSR flow showing bootstrap data loss

In createLDReactProvider (LDReactProvider.tsx:122-132):

  1. createClient(id, ctx, ldOptions) → detects SSR → returns createNoopClient() (no bootstrap)
  2. client.start({ bootstrap: data }) → noop start ignores the argument
  3. Bootstrap data is lost; noop client behaves as if no bootstrap was provided
Prompt for agents
In packages/sdk/react/src/client/LDReactClient.tsx, the SSR path at line 52 calls createNoopClient() without any bootstrap data. The fix needs to propagate bootstrap data from createLDReactProvider to createNoopClient during SSR. There are several approaches:

1. Add a bootstrap parameter to createClient's options (e.g., extend LDReactClientOptions or add a separate parameter), and pass it through to createNoopClient when SSR is detected. Then in createLDReactProvider (LDReactProvider.tsx:125), pass the bootstrap data via createClient.

2. Alternatively, detect SSR in createLDReactProvider itself (before calling createClient) and create the noop client directly with the bootstrap data, bypassing createClient entirely.

The key requirement is that when bootstrap data is available during SSR, createNoopClient(bootstrap) must be called instead of createNoopClient().
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options;
Expand Down
Loading
Loading