-
Notifications
You must be signed in to change notification settings - Fork 35
feat: support static client component rendering #1227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| * | ||
|
|
@@ -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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), As a result, during SSR:
SSR flow showing bootstrap data lossIn
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.