diff --git a/packages/sdk/react/__tests__/client/LDReactClient.test.ts b/packages/sdk/react/__tests__/client/LDReactClient.test.ts index 8f4b6b936..ff7ae2137 100644 --- a/packages/sdk/react/__tests__/client/LDReactClient.test.ts +++ b/packages/sdk/react/__tests__/client/LDReactClient.test.ts @@ -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; diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts new file mode 100644 index 000000000..2f3168940 --- /dev/null +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -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(); + }); +}); diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index f383adc93..f2b5c1eda 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -2,9 +2,6 @@ import { createClient as createBaseClient, LDContext, LDContextStrict, - type LDEvaluationDetailTyped, - LDEvaluationReason, - type LDFlagValue, LDIdentifyOptions, LDIdentifyResult, LDOptions, @@ -12,76 +9,10 @@ import { 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(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, - 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, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - numberVariation: (_key: string, defaultValue: number) => defaultValue, - numberVariationDetail: (key: string, defaultValue: number) => - noopDetail(defaultValue) as LDEvaluationDetailTyped, - 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, - track: () => {}, - variation: (_key: string, defaultValue?: LDFlagValue) => defaultValue ?? null, - variationDetail: (key: string, defaultValue?: LDFlagValue) => { - const def = defaultValue ?? null; - return noopDetail(def) as LDEvaluationDetailTyped; - }, - 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. * @@ -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(); } const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; diff --git a/packages/sdk/react/src/client/createNoopClient.ts b/packages/sdk/react/src/client/createNoopClient.ts new file mode 100644 index 000000000..f5603588c --- /dev/null +++ b/packages/sdk/react/src/client/createNoopClient.ts @@ -0,0 +1,100 @@ +import type { LDReactClient } from './LDClient'; + +// NOTE: We intentionally do not check `$valid` here. During SSR, we serve bootstrap +// flags on a best-effort basis regardless of validity since there is no live connection +// to LaunchDarkly. The client-side SDK will re-evaluate once hydrated. +function extractFlags(bootstrap: object): Record { + return Object.fromEntries(Object.entries(bootstrap).filter(([key]) => !key.startsWith('$'))); +} + +/** + * @internal + * + * Creates an {@link LDReactClient} stub that returns default values for all + * variation calls. Intended for use during server-side rendering of `'use client'` + * components where no real client is available. + * + * @remarks + * Optionally accepts bootstrap data that will be applied to the client-side SDK. + * Learn more about bootstrap data in the [LaunchDarkly documentation](https://launchdarkly.com/docs/sdk/features/bootstrapping). + * + * **NOTE:** The client will also ad-hoc evaluate the flags on the server-side from bootstrap data. + * This could be useful to have pre-evaluated flags available before client-side hydration. It should + * be noted that this "pre-evaluation" ONLY provides the value flag, if you need flag details, then + * you will not get that until the client-side SDK is ready and did its own evaluation. + * + * @param bootstrap Optional bootstrap data object. + * @returns An {@link LDReactClient} noop stub, optionally enriched with bootstrap data. + */ +export function createNoopClient(bootstrap?: object): LDReactClient { + const flags = extractFlags(bootstrap ?? {}); + const hasBootstrap = bootstrap !== undefined; + + function getVariation(key: string, defaultValue: T, typeCheck: (v: unknown) => v is T): T { + if (key in flags && typeCheck(flags[key])) { + return flags[key] as T; + } + return defaultValue; + } + + function getJsonVariation(key: string, defaultValue: unknown): unknown { + if (key in flags) { + return flags[key]; + } + return defaultValue; + } + + function getDetail(key: string, defaultValue: T, typeCheck?: (v: unknown) => v is T) { + const value = typeCheck + ? getVariation(key, defaultValue, typeCheck) + : getJsonVariation(key, defaultValue); + return { + value, + variationIndex: null, + reason: null, + }; + } + + const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean'; + const isNumber = (v: unknown): v is number => typeof v === 'number'; + const isString = (v: unknown): v is string => typeof v === 'string'; + + const noop = () => {}; + const noopUnsub = () => noop; + const noopPromise = () => Promise.resolve(); + + return { + allFlags: () => ({ ...flags }), + getContext: () => undefined, + getInitializationState: () => (hasBootstrap ? 'complete' : 'initializing'), + getInitializationError: () => undefined, + isReady: () => hasBootstrap, + boolVariation: (key: string, def: boolean) => getVariation(key, def, isBoolean), + numberVariation: (key: string, def: number) => getVariation(key, def, isNumber), + stringVariation: (key: string, def: string) => getVariation(key, def, isString), + jsonVariation: (key: string, def: unknown) => getJsonVariation(key, def), + boolVariationDetail: (key: string, def: boolean) => getDetail(key, def, isBoolean), + numberVariationDetail: (key: string, def: number) => getDetail(key, def, isNumber), + stringVariationDetail: (key: string, def: string) => getDetail(key, def, isString), + jsonVariationDetail: (key: string, def: unknown) => getDetail(key, def), + variation: (key: string, def: unknown) => getJsonVariation(key, def), + variationDetail: (key: string, def: unknown) => getDetail(key, def), + + // The following methods should not be accessible in the server runtime assuming + // normal usage of this SDK. So we are more lax with their stubs. + on: noop, + off: noop, + onContextChange: noopUnsub, + onInitializationStatusChange: noopUnsub, + shouldUseCamelCaseFlagKeys: () => true, + close: noopPromise, + flush: noopPromise, + identify: noopPromise, + track: noop, + addHook: noop, + waitForInitialization: noopPromise, + setStreaming: noop, + start: noopPromise, + logger: { debug: noop, info: noop, warn: noop, error: noop }, + } as unknown as LDReactClient; +}