From 5703bc81111e91c6aeb5db681bed7916be2aa8f2 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 3 Apr 2026 18:25:35 -0500 Subject: [PATCH 1/9] feat: adding start() method to common client sdk package --- .../browser/__tests__/BrowserClient.test.ts | 525 +----------------- packages/sdk/browser/src/BrowserClient.ts | 82 +-- packages/sdk/browser/src/LDClient.ts | 18 +- .../electron/__tests__/ElectronClient.test.ts | 12 +- packages/sdk/electron/src/ElectronClient.ts | 82 +-- packages/sdk/electron/src/LDClient.ts | 16 +- .../__tests__/LDClientImpl.start.test.ts | 318 +++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 116 +++- .../sdk-client/src/api/LDStartOptions.ts | 20 + packages/shared/sdk-client/src/api/index.ts | 1 + .../src/configuration/Configuration.ts | 6 + packages/shared/sdk-client/src/index.ts | 1 + 12 files changed, 479 insertions(+), 718 deletions(-) create mode 100644 packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts create mode 100644 packages/shared/sdk-client/src/api/LDStartOptions.ts diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 391f8829c4..e373ff893e 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -1,8 +1,4 @@ -import { - AutoEnvAttributes, - LDLogger, - LDSingleKindContext, -} from '@launchdarkly/js-client-sdk-common'; +import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; import { makeClient } from '../src/BrowserClient'; import { makeBasicPlatform } from './BrowserClient.mocks'; @@ -181,7 +177,7 @@ describe('given a mock platform for a BrowserClient', () => { }); }); - it('can use bootstrap data', async () => { + it('can use bootstrap data via start()', async () => { const client = makeClient( 'client-side-id', { kind: 'user', key: 'bob' }, @@ -209,477 +205,25 @@ describe('given a mock platform for a BrowserClient', () => { }); }); - it('can evaluate flags with bootstrap data before identify completes', async () => { - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'bob' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - }, - platform, - ); - - const identifyPromise = client.start({ - identifyOptions: { - bootstrap: goodBootstrapDataWithReasons, - }, - }); - - const flagValue = client.jsonVariationDetail('json', undefined); - expect(flagValue).toEqual({ - reason: { - kind: 'OFF', - }, - value: ['a', 'b', 'c', 'd'], - variationIndex: 1, - }); - - expect(client.getContext()).toBeUndefined(); - - // Wait for identify to complete - await identifyPromise; - - // Verify that active context is now set - expect(client.getContext()).toEqual({ kind: 'user', key: 'bob' }); - }); - - it('parses bootstrap data only once when using start()', async () => { - const bootstrapModule = await import('@launchdarkly/js-client-sdk-common'); - const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); - - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'bob' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - }, - platform, - ); - - await client.start({ - identifyOptions: { - bootstrap: goodBootstrapDataWithReasons, - }, - }); - - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith( - expect.anything(), - goodBootstrapDataWithReasons, - ); - - readFlagsFromBootstrapSpy.mockRestore(); - }); - - it('uses the latest bootstrap data when identify is called with new bootstrap data', async () => { - const initialBootstrapData = { - 'string-flag': 'is bob', - 'my-boolean-flag': false, - $flagsState: { - 'string-flag': { - variation: 1, - version: 3, - }, - 'my-boolean-flag': { - variation: 1, - version: 11, - }, - }, - $valid: true, - }; - - const newBootstrapData = { - 'string-flag': 'is alice', - 'my-boolean-flag': true, - $flagsState: { - 'string-flag': { - variation: 1, - version: 4, - }, - 'my-boolean-flag': { - variation: 0, - version: 12, - }, - }, - $valid: true, - }; - - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'bob' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - }, - platform, - ); - - await client.start({ - identifyOptions: { - bootstrap: initialBootstrapData, - }, - }); - - expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); - expect(client.boolVariation('my-boolean-flag', false)).toBe(false); - - await client.identify( - { kind: 'user', key: 'alice' }, - { - bootstrap: newBootstrapData, - }, - ); - - expect(client.stringVariation('string-flag', 'default')).toBe('is alice'); - expect(client.boolVariation('my-boolean-flag', false)).toBe(true); - }); - - it('can shed intermediate identify calls', async () => { + it('returns the same promise when start is called multiple times', async () => { const client = makeClient( 'client-side-id', - { key: 'user-key-0', kind: 'user' }, + { kind: 'user', key: 'user-key' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); - const promise0 = client.start(); - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); - const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); - const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - - const [result0, result1, result2, result3] = await Promise.all([ - promise0, - promise1, - promise2, - promise3, - ]); - - expect(result0).toEqual({ status: 'complete' }); - expect(result1).toEqual({ status: 'shed' }); - expect(result2).toEqual({ status: 'shed' }); - expect(result3).toEqual({ status: 'completed' }); - // With events and goals disabled the only fetch calls should be for polling requests. - expect(platform.requests.fetch.mock.calls.length).toBe(2); - }); - - it('calls beforeIdentify in order', async () => { - const order: string[] = []; - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'user-key-0' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - sendEvents: false, - fetchGoals: false, - hooks: [ - { - beforeIdentify: (hookContext, data) => { - if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { - order.push((hookContext.context as LDSingleKindContext).key); - } - - return data; - }, - getMetadata: () => ({ - name: 'test-hook', - version: '1.0.0', - }), - }, - ], - }, - platform, - ); - - await client.start(); - - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); - const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); - const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - - await Promise.all([promise1, promise2, promise3]); - expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); - }); - - it('completes identify calls in order', async () => { - const order: string[] = []; - const client = makeClient( - 'client-side-id', - { key: 'user-key-1', kind: 'user' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - sendEvents: false, - fetchGoals: false, - hooks: [ - { - afterIdentify: (hookContext, data, result) => { - if (result.status === 'shed') { - return data; - } - if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { - order.push((hookContext.context as LDSingleKindContext).key); - } - - return data; - }, - getMetadata: () => ({ - name: 'test-hook', - version: '1.0.0', - }), - }, - ], - }, - platform, - ); - const promise1 = client.start(); - const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); - const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - - await Promise.all([promise1, promise2, promise3]); - // user-key-2 is shed, so it is not included in the order - expect(order).toEqual(['user-key-1', 'user-key-3']); - }); - - it('completes awaited identify calls in order without shedding', async () => { - const order: string[] = []; - const client = makeClient( - 'client-side-id', - { key: 'user-key-0', kind: 'user' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - sendEvents: false, - fetchGoals: false, - hooks: [ - { - afterIdentify: (hookContext, data, result) => { - if (result.status === 'shed') { - return data; - } - if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { - order.push((hookContext.context as LDSingleKindContext).key); - } - - return data; - }, - getMetadata: () => ({ - name: 'test-hook', - version: '1.0.0', - }), - }, - ], - }, - platform, - ); - - await client.start(); - - const result1 = await client.identify({ key: 'user-key-1', kind: 'user' }); - const result2 = await client.identify({ key: 'user-key-2', kind: 'user' }); - const result3 = await client.identify({ key: 'user-key-3', kind: 'user' }); - - expect(result1.status).toEqual('completed'); - expect(result2.status).toEqual('completed'); - expect(result3.status).toEqual('completed'); - - // user-key-2 is shed, so it is not included in the order - expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); - }); - - it('can shed intermediate identify calls without waiting for results', async () => { - const client = makeClient( - 'client-side-id', - { key: 'user-key-0', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - platform, - ); - - await client.start(); - - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); - const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); - const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - - await Promise.all([promise1, promise2, promise3]); - - // With events and goals disabled the only fetch calls should be for polling requests. - expect(platform.requests.fetch.mock.calls.length).toBe(3); - }); - - it('it does not shed non-shedable identify calls', async () => { - const client = makeClient( - 'client-side-id', - { key: 'user-key-0', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - platform, - ); - - await client.start(); - - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }, { sheddable: false }); - const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }, { sheddable: false }); - const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }, { sheddable: false }); - - const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); - - expect(result1).toEqual({ status: 'completed' }); - expect(result2).toEqual({ status: 'completed' }); - expect(result3).toEqual({ status: 'completed' }); - // With events and goals disabled the only fetch calls should be for polling requests. - expect(platform.requests.fetch.mock.calls.length).toBe(4); - }); - - it('blocks until the client is ready when waitForInitialization is called', async () => { - const client = makeClient( - 'client-side-id', - { key: 'user-key', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - platform, - ); - - const waitPromise = client.waitForInitialization({ timeout: 10 }); - const startPromise = client.start(); - - await Promise.all([waitPromise, startPromise]); - - await expect(waitPromise).resolves.toEqual({ status: 'complete' }); - await expect(startPromise).resolves.toEqual({ status: 'complete' }); - }); - - it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => { - jest.useRealTimers(); - - // Create a platform with a delayed fetch response - const delayedPlatform = makeBasicPlatform(); - let resolveFetch: (value: any) => void; - const delayedFetchPromise = new Promise((resolve) => { - resolveFetch = resolve; - }); - - // Mock fetch to return a promise that won't resolve until we explicitly resolve it - delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) => - delayedFetchPromise.then(() => ({})), - ) as any; - - const client = makeClient( - 'client-side-id', - { key: 'user-key', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - delayedPlatform, - ); - - client.start(); - - // Call waitForInitialization with a short timeout (0.1 seconds) - const waitPromise = client.waitForInitialization({ timeout: 0.1 }); - - // Verify that waitForInitialization rejects with a timeout error - await expect(waitPromise).resolves.toEqual({ status: 'timeout' }); - - // Clean up: resolve the fetch to avoid hanging promises and restore fake timers - resolveFetch!({}); - jest.useFakeTimers(); - }); - - it('resolves waitForInitialization with failed status immediately when identify fails', async () => { - const errorPlatform = makeBasicPlatform(); - const identifyError = new Error('Network error'); - - // Mock fetch to reject with an error - errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => - Promise.reject(identifyError), - ) as any; - - const client = makeClient( - 'client-side-id', - { key: 'user-key', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - errorPlatform, - ); - - // Call waitForInitialization first - this creates the promise - const waitPromise = client.waitForInitialization({ timeout: 10 }); - - // Start identify which will fail - const identifyPromise = client.start(); - - await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries - - // Wait for identify to fail - await expect(identifyPromise).resolves.toEqual({ - status: 'failed', - error: identifyError, - }); - - // Verify that waitForInitialization returns immediately with failed status - await expect(waitPromise).resolves.toEqual({ - status: 'failed', - error: identifyError, - }); - }); - - it('resolves waitForInitialization with failed status when identify fails before waitForInitialization is called', async () => { - const errorPlatform = makeBasicPlatform(); - const identifyError = new Error('Network error'); - - // Mock fetch to reject with an error - errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => - Promise.reject(identifyError), - ) as any; - - const client = makeClient( - 'client-side-id', - { key: 'user-key', kind: 'user' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - errorPlatform, - ); - - // Start identify which will fail BEFORE waitForInitialization is called - const identifyPromise = client.start(); - - await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries - - // Wait for identify to fail - await expect(identifyPromise).resolves.toEqual({ - status: 'failed', - error: identifyError, - }); + const promise2 = client.start(); - // Now call waitForInitialization AFTER identify has already failed - // It should return the failed status immediately, not timeout - const waitPromise = client.waitForInitialization({ timeout: 10 }); + expect(promise1).toBe(promise2); - // Verify that waitForInitialization returns immediately with failed status - await expect(waitPromise).resolves.toEqual({ - status: 'failed', - error: identifyError, - }); + const result = await promise1; + expect(result.status).toBe('complete'); }); - it('returns the same promise when start is called multiple times', async () => { + it('cannot call identify before start', async () => { const client = makeClient( 'client-side-id', { kind: 'user', key: 'user-key' }, @@ -688,25 +232,12 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - // Call start multiple times before it completes - const promise1 = client.start(); - const promise2 = client.start(); - const promise3 = client.start(); - - // Verify all promises are the same reference - // The implementation should cache the promise and return the same one - expect(promise1).toBe(promise2); - expect(promise2).toBe(promise3); - expect(promise1).toBe(promise3); - - // Verify all promises resolve to the same value - const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); - expect(result1).toEqual(result2); - expect(result2).toEqual(result3); - expect(result1.status).toBe('complete'); + const result = await client.identify({ kind: 'user', key: 'new-user-key' }); - // Verify that only one identify call was made (one for polling) - expect(platform.requests.fetch.mock.calls.length).toBe(1); + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Identify called before start'); + } }); describe('automatic streaming state based on event listeners', () => { @@ -845,34 +376,6 @@ describe('given a mock platform for a BrowserClient', () => { }); }); - it('cannot call identify before start', async () => { - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'user-key' }, - AutoEnvAttributes.Disabled, - { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, - platform, - ); - - // Call identify before start - const result = await client.identify({ kind: 'user', key: 'new-user-key' }); - - // Verify that identify returns an error status - expect(result.status).toBe('error'); - if (result.status === 'error') { - expect(result.error).toBeInstanceOf(Error); - expect(result.error.message).toBe('Identify called before start'); - } - - // Verify that the logger was called with the error message - expect(logger.error).toHaveBeenCalledWith( - 'Client must be started before it can identify a context, did you forget to call start()?', - ); - - // Verify that no fetch calls were made - expect(platform.requests.fetch.mock.calls.length).toBe(0); - }); - it('uses FDv1 endpoints when dataSystem is not set', async () => { const client = makeClient( 'client-side-id', diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 6d6660aad4..6f21cb1c2e 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -21,10 +21,8 @@ import { LDIdentifyResult, LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, - LDWaitForInitializationResult, MODE_TABLE, Platform, - readFlagsFromBootstrap, resolveForegroundMode, safeRegisterDebugOverridePlugins, } from '@launchdarkly/js-client-sdk-common'; @@ -45,11 +43,6 @@ class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; - private _initialContext?: LDContext; - - // NOTE: This also keeps track of when we tried to initialize the client. - private _startPromise?: Promise; - constructor( clientSideId: string, autoEnvAttributes: AutoEnvAttributes, @@ -156,6 +149,7 @@ class BrowserClientImpl extends LDClientImpl { getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), credentialType: 'clientSideId', + requiresStart: true, }); this.setEventSendingEnabled(true, false); @@ -234,10 +228,6 @@ class BrowserClientImpl extends LDClientImpl { } } - setInitialContext(context: LDContext): void { - this._initialContext = context; - } - override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { return super.identify(context, identifyOptions); } @@ -246,79 +236,11 @@ class BrowserClientImpl extends LDClientImpl { context: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { - if (!this._startPromise) { - this.logger.error( - 'Client must be started before it can identify a context, did you forget to call start()?', - ); - return { status: 'error', error: new Error('Identify called before start') }; - } - - const identifyOptionsWithUpdatedDefaults = { - ...identifyOptions, - }; - if (identifyOptions?.sheddable === undefined) { - identifyOptionsWithUpdatedDefaults.sheddable = true; - } - - const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); - + const res = await super.identifyResult(context, identifyOptions); this._goalManager?.startTracking(); return res; } - start(options?: LDStartOptions): Promise { - if (this.initializeResult) { - return Promise.resolve(this.initializeResult); - } - if (this._startPromise) { - return this._startPromise; - } - if (!this._initialContext) { - this.logger.error('Initial context not set'); - return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); - } - - // When we get to this point, we assume this is the first time that start is being - // attempted. This line should only be called once during the lifetime of the client. - const identifyOptions = { - ...(options?.identifyOptions ?? {}), - - // Initial identify operations are not sheddable. - sheddable: false, - }; - - // If the bootstrap data is provided in the start options, and the identify options do not have bootstrap data, - // then use the bootstrap data from the start options. - if (options?.bootstrap && !identifyOptions.bootstrap) { - identifyOptions.bootstrap = options.bootstrap; - } - - if (identifyOptions?.bootstrap) { - try { - if (!identifyOptions.bootstrapParsed) { - identifyOptions.bootstrapParsed = readFlagsFromBootstrap( - this.logger, - identifyOptions.bootstrap, - ); - } - this.presetFlags(identifyOptions.bootstrapParsed!); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - - if (!this.initializedPromise) { - this.initializedPromise = new Promise((resolve) => { - this.initResolve = resolve; - }); - } - - this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); - - this.identifyResult(this._initialContext!, identifyOptions); - return this._startPromise; - } - setConnectionMode(mode?: FDv2ConnectionMode): void { if (!this.dataManager.setConnectionMode) { this.logger.warn( diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 7de1ea0d2d..5a86a4835e 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -1,5 +1,6 @@ import { LDClient as CommonClient, + LDStartOptions as CommonLDStartOptions, FDv2ConnectionMode, LDContext, LDIdentifyResult, @@ -9,18 +10,11 @@ import { import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; -export interface LDStartOptions extends LDWaitForInitializationOptions { - /** - * Optional bootstrap data to use for the identify operation. If {@link LDIdentifyOptions.bootstrap} is provided, it will be ignored. - */ - bootstrap?: unknown; - - /** - * Optional identify options to use for the identify operation. See {@link LDIdentifyOptions} for more information. - * - * @remarks - * Since the first identify option should never be sheddable, we omit the sheddable option from the interface to avoid confusion. - */ +/** + * Browser-specific start options that extend the common start options + * with browser-specific identify options (see {@link LDIdentifyOptions}). + */ +export interface LDStartOptions extends CommonLDStartOptions { identifyOptions?: Omit; } diff --git a/packages/sdk/electron/__tests__/ElectronClient.test.ts b/packages/sdk/electron/__tests__/ElectronClient.test.ts index 1139dee269..b6d3dc5875 100644 --- a/packages/sdk/electron/__tests__/ElectronClient.test.ts +++ b/packages/sdk/electron/__tests__/ElectronClient.test.ts @@ -766,10 +766,7 @@ it('can use bootstrap data with identify', async () => { expect(mockedCreateEventSource).toHaveBeenCalled(); }); -it('parses bootstrap data only once when identify is called with bootstrap', async () => { - const commonModule = await import('@launchdarkly/js-client-sdk-common'); - const readFlagsFromBootstrapSpy = jest.spyOn(commonModule, 'readFlagsFromBootstrap'); - +it('parses bootstrap data when start is called with bootstrap', async () => { (ElectronPlatform as jest.Mock).mockReturnValue({ crypto: new ElectronCrypto(), info: new ElectronInfo(), @@ -796,7 +793,8 @@ it('parses bootstrap data only once when identify is called with bootstrap', asy await client.start({ bootstrap: goodBootstrapData }); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); - expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith(expect.anything(), goodBootstrapData); - readFlagsFromBootstrapSpy.mockRestore(); + // Verify that bootstrap data was parsed and flags are available. + expect(client.allFlags().killswitch).toBe(true); + expect(client.allFlags()['string-flag']).toBe('is bob'); + expect(client.allFlags().cat).toBe(false); }); diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 5b3baf91e3..d42cdf1ed1 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -17,13 +17,10 @@ import { LDFlagValue, LDHeaders, LDIdentifyOptions, - LDIdentifyResult, - LDLogger, LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, LDWaitForInitializationResult, mobileFdv1Endpoints, - readFlagsFromBootstrap, } from '@launchdarkly/js-client-sdk-common'; import ElectronDataManager from './ElectronDataManager'; @@ -43,10 +40,6 @@ import ElectronPlatform from './platform/ElectronPlatform'; const VALID_LOG_LEVELS: ReadonlySet = new Set(['error', 'warn', 'info', 'debug']); export class ElectronClient extends LDClientImpl { - private readonly _initialContext: LDContext; - - private _startPromise?: Promise; - private readonly _plugins: LDPlugin[]; private _ipcNamespace?: string; @@ -93,6 +86,7 @@ export class ElectronClient extends LDClientImpl { getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, _environmentMetadata, validatedElectronOptions.plugins), credentialType: useClientSideId ? 'clientSideId' : 'mobileKey', + requiresStart: true, }; const platform = new ElectronPlatform(logger, options); @@ -125,7 +119,7 @@ export class ElectronClient extends LDClientImpl { internalOptions, ); - this._initialContext = initialContext; + this.setInitialContext(initialContext); this._plugins = validatedElectronOptions.plugins; this.setEventSendingEnabled(!this.isOffline(), false); @@ -142,78 +136,6 @@ export class ElectronClient extends LDClientImpl { internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins); } - start(options?: LDStartOptions): Promise { - if (this.initializeResult !== undefined) { - return Promise.resolve(this.initializeResult); - } - if (this._startPromise) { - return this._startPromise; - } - if (!this._initialContext) { - this.logger.error('Initial context not set'); - return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); - } - - const identifyOptions: LDIdentifyOptions = { - ...(options?.identifyOptions ?? {}), - sheddable: false, - }; - - if ( - options?.bootstrap !== undefined && - options?.bootstrap !== null && - !identifyOptions.bootstrap - ) { - identifyOptions.bootstrap = options.bootstrap; - } - - if (identifyOptions.bootstrap) { - try { - if (!identifyOptions.bootstrapParsed) { - identifyOptions.bootstrapParsed = readFlagsFromBootstrap( - this.logger, - identifyOptions.bootstrap, - ); - } - this.presetFlags(identifyOptions.bootstrapParsed!); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - - if (!this.initializedPromise) { - this.initializedPromise = new Promise((resolve) => { - this.initResolve = resolve; - }); - } - - this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); - - this.identifyResult(this._initialContext, identifyOptions); - return this._startPromise; - } - - override async identifyResult( - pristineContext: LDContext, - identifyOptions?: LDIdentifyOptions, - ): Promise { - if (!this._startPromise) { - this.logger.error( - 'Client must be started before it can identify a context, did you forget to call start()?', - ); - return { status: 'error', error: new Error('Identify called before start') }; - } - - const identifyOptionsWithUpdatedDefaults = { - ...identifyOptions, - }; - if (identifyOptions?.sheddable === undefined) { - identifyOptionsWithUpdatedDefaults.sheddable = true; - } - - return super.identifyResult(pristineContext, identifyOptionsWithUpdatedDefaults); - } - async setConnectionMode(mode: ConnectionMode): Promise { if (mode === 'offline') { this.setEventSendingEnabled(false, true); diff --git a/packages/sdk/electron/src/LDClient.ts b/packages/sdk/electron/src/LDClient.ts index 4f1815e89f..187817f706 100644 --- a/packages/sdk/electron/src/LDClient.ts +++ b/packages/sdk/electron/src/LDClient.ts @@ -4,23 +4,11 @@ import type { LDContext, LDIdentifyOptions, LDIdentifyResult, - LDWaitForInitializationOptions, + LDStartOptions, LDWaitForInitializationResult, } from '@launchdarkly/js-client-sdk-common'; -export interface LDStartOptions extends LDWaitForInitializationOptions { - /** - * Optional bootstrap data to use for the identify operation. If - * {@link LDIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. - */ - bootstrap?: unknown; - - /** - * Optional identify options to use for the first identify. Since the first identify is not - * sheddable, the sheddable option is omitted from this type. - */ - identifyOptions?: Omit; -} +export type { LDStartOptions }; export interface LDClient extends Omit { /** diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts new file mode 100644 index 0000000000..7f736183e2 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -0,0 +1,318 @@ +import { AutoEnvAttributes, clone, Hasher, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDContext } from '../src/api/LDContext'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { goodBootstrapData, goodBootstrapDataWithReasons } from './flag-manager/testBootstrapData'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'user', key: 'test-user' }; + +let clients: LDClientImpl[] = []; + +function setupClient( + mockPlatform: ReturnType, + options?: { + logger?: LDLogger; + requiresStart?: boolean; + disableNetwork?: boolean; + sendEvents?: boolean; + }, +) { + const logger = options?.logger ?? { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + const ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: options?.sendEvents ?? false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform, { + disableNetwork: options?.disableNetwork, + }), + { + getImplementationHooks: () => [], + credentialType: 'clientSideId', + requiresStart: options?.requiresStart ?? true, + }, + ); + + clients.push(ldc); + return { ldc, logger }; +} + +function setupStreamingPlatform() { + const mockPlatform = createBasicPlatform(); + const defaultPutResponse = clone(mockResponseJson); + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => 'digested1'), + }; + mockPlatform.crypto.createHash.mockReturnValue(hasher); + mockPlatform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + const mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }, + ); + return mockPlatform; +} + +describe('LDClientImpl.start()', () => { + afterEach(async () => { + await Promise.all(clients.map((c) => c.close())); + clients = []; + jest.resetAllMocks(); + }); + + it('returns failed status when initial context is not set', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + + const result = await ldc.start(); + expect(result).toEqual({ + status: 'failed', + error: expect.any(Error), + }); + }); + + it('returns the same promise when called multiple times', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + ldc.setInitialContext(context); + + const promise1 = ldc.start(); + const promise2 = ldc.start(); + const promise3 = ldc.start(); + + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + expect(result1.status).toBe('complete'); + }); + + it('returns cached result after initialization completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + ldc.setInitialContext(context); + + const result1 = await ldc.start(); + expect(result1.status).toBe('complete'); + + const result2 = await ldc.start(); + expect(result2.status).toBe('complete'); + }); + + it('resolves with complete status on successful identify', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + ldc.setInitialContext(context); + + const result = await ldc.start(); + expect(result.status).toBe('complete'); + }); + + it('sets the active context after start completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + ldc.setInitialContext(context); + + expect(ldc.getContext()).toBeUndefined(); + await ldc.start(); + expect(ldc.getContext()).toEqual(context); + }); + + describe('bootstrap data', () => { + it('presets flags from bootstrap in identifyOptions', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapData }, + }); + + const flags = ldc.allFlags(); + expect(flags.killswitch).toBe(true); + expect(flags['string-flag']).toBe('is bob'); + expect(flags.cat).toBe(false); + }); + + it('presets flags from top-level bootstrap option', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + await ldc.start({ bootstrap: goodBootstrapData }); + + const flags = ldc.allFlags(); + expect(flags.killswitch).toBe(true); + expect(flags['string-flag']).toBe('is bob'); + }); + + it('makes flags available synchronously before identify completes', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + const startPromise = ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + const flags = ldc.allFlags(); + expect(flags['string-flag']).toBe('is bob'); + expect(flags.killswitch).toBe(true); + + await startPromise; + }); + + it('supports bootstrap data with evaluation reasons', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + expect(ldc.jsonVariationDetail('json', undefined)).toEqual({ + reason: { kind: 'OFF' }, + value: ['a', 'b', 'c', 'd'], + variationIndex: 1, + }); + }); + + it('prefers identifyOptions.bootstrap over top-level bootstrap', async () => { + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + const differentBootstrap = { + 'other-flag': true, + $flagsState: { 'other-flag': { variation: 0, version: 1 } }, + $valid: true, + }; + + await ldc.start({ + bootstrap: goodBootstrapData, + identifyOptions: { bootstrap: differentBootstrap }, + }); + + const flags = ldc.allFlags(); + expect(flags['other-flag']).toBe(true); + expect(flags.killswitch).toBeUndefined(); + }); + }); + + describe('requiresStart guard', () => { + it('blocks identify before start when requiresStart is true', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc, logger } = setupClient(mockPlatform, { requiresStart: true }); + ldc.setInitialContext(context); + + const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' }); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Identify called before start'); + } + expect(logger.error).toHaveBeenCalledWith( + 'Client must be started before it can identify a context, did you forget to call start()?', + ); + }); + + it('allows identify after start when requiresStart is true', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: true }); + ldc.setInitialContext(context); + + await ldc.start(); + + const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' }); + expect(result.status).toBe('completed'); + }); + + it('allows identify without start when requiresStart is false', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: false }); + + const result = await ldc.identifyResult(context); + expect(result.status).toBe('completed'); + }); + + it('defaults sheddable to true for post-start identifies when requiresStart is true', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: true }); + ldc.setInitialContext(context); + + const startPromise = ldc.start(); + const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' }); + const promise2 = ldc.identifyResult({ kind: 'user', key: 'user-2' }); + const promise3 = ldc.identifyResult({ kind: 'user', key: 'user-3' }); + + const [startResult, result1, result2, result3] = await Promise.all([ + startPromise, + promise1, + promise2, + promise3, + ]); + + expect(startResult.status).toBe('complete'); + expect(result1.status).toBe('shed'); + expect(result2.status).toBe('shed'); + expect(result3.status).toBe('completed'); + }); + + it('does not default sheddable when requiresStart is false', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform, { requiresStart: false }); + + const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' }); + const promise2 = ldc.identifyResult({ kind: 'user', key: 'user-2' }); + const promise3 = ldc.identifyResult({ kind: 'user', key: 'user-3' }); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + expect(result1.status).toBe('completed'); + expect(result2.status).toBe('completed'); + expect(result3.status).toBe('completed'); + }); + }); + + describe('waitForInitialization integration', () => { + it('resolves waitForInitialization when start completes', async () => { + const mockPlatform = setupStreamingPlatform(); + const { ldc } = setupClient(mockPlatform); + ldc.setInitialContext(context); + + const waitPromise = ldc.waitForInitialization({ timeout: 10 }); + const startPromise = ldc.start(); + + const [waitResult, startResult] = await Promise.all([waitPromise, startPromise]); + + expect(waitResult.status).toBe('complete'); + expect(startResult.status).toBe('complete'); + }); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 2a05f477dd..e57e19a086 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -28,6 +28,7 @@ import { LDIdentifySuccess, LDIdentifyTimeout, type LDOptions, + LDStartOptions, LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationOptions, @@ -53,6 +54,7 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; +import { readFlagsFromBootstrap } from './flag-manager/bootstrap'; import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager'; import { FlagChangeType } from './flag-manager/FlagUpdater'; import { ItemDescriptor } from './flag-manager/ItemDescriptor'; @@ -98,6 +100,13 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { protected initResolve?: (result: LDWaitForInitializationResult) => void; protected initializeResult?: LDWaitForInitializationResult; + // NOTE: this is used to ease the transition to the new initialization pattern. + // All client SDKs should set this to true with the exception of React Native which is + // still using the deprecated construct + identify pattern. + private _requiresStart: boolean = false; + protected initialContext?: LDContext; + protected startPromise?: Promise; + /** * Creates the client object synchronously. No async, no network calls. */ @@ -119,6 +128,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._config = new ConfigurationImpl(options, internalOptions); this.logger = this._config.logger; + this._requiresStart = internalOptions?.requiresStart ?? false; this._baseHeaders = defaultHeaders( this.sdkKey, @@ -265,6 +275,75 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._flagManager.presetFlags(newFlags); } + /** + * Sets the initial context for the client. This must be called before `start()`. + * @param context The initial context. + */ + setInitialContext(context: LDContext): void { + this.initialContext = context; + } + + /** + * Starts the client and returns a promise that resolves to the initialization result. + * + * This method is idempotent — calling it multiple times returns the same promise. + * + * @param options Optional configuration. See {@link LDStartOptions}. + * @returns A promise that resolves to the initialization result. + */ + start(options?: LDStartOptions): Promise { + if (this.initializeResult) { + return Promise.resolve(this.initializeResult); + } + if (this.startPromise) { + return this.startPromise; + } + if (!this.initialContext) { + this.logger.error('Initial context not set'); + return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); + } + + const identifyOptions: LDIdentifyOptions = { + ...(options?.identifyOptions ?? {}), + + // Initial identify operations are not sheddable. + sheddable: false, + }; + + // If the bootstrap data is provided in the start options, and the identify options do not + // have bootstrap data, then use the bootstrap data from the start options. + if (options?.bootstrap && !identifyOptions.bootstrap) { + identifyOptions.bootstrap = options.bootstrap; + } + + if (identifyOptions.bootstrap) { + try { + if (!identifyOptions.bootstrapParsed) { + identifyOptions.bootstrapParsed = readFlagsFromBootstrap( + this.logger, + identifyOptions.bootstrap, + ); + } + if (identifyOptions.bootstrapParsed) { + this.presetFlags(identifyOptions.bootstrapParsed); + } + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + if (!this.initializedPromise) { + this.initializedPromise = new Promise((resolve) => { + this.initResolve = resolve; + }); + } + + this.startPromise = this._promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); + + this.identifyResult(this.initialContext!, identifyOptions); + return this.startPromise; + } + /** * Identifies a context to LaunchDarkly. See {@link LDClient.identify}. * @@ -307,8 +386,21 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { pristineContext: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { - const identifyTimeout = identifyOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS; - const noTimeout = identifyOptions?.timeout === undefined && identifyOptions?.noTimeout === true; + if (this._requiresStart && !this.startPromise) { + this.logger.error( + 'Client must be started before it can identify a context, did you forget to call start()?', + ); + return { status: 'error', error: new Error('Identify called before start') }; + } + + let effectiveOptions = identifyOptions; + if (this._requiresStart && identifyOptions?.sheddable === undefined) { + effectiveOptions = { ...identifyOptions, sheddable: true }; + } + + const identifyTimeout = effectiveOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS; + const noTimeout = + effectiveOptions?.timeout === undefined && effectiveOptions?.noTimeout === true; // When noTimeout is specified, and a timeout is not specified, then this condition cannot // be encountered. (Our default would need to be greater) @@ -330,7 +422,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } const checkedContext = Context.fromLDContext(context); if (checkedContext.valid) { - const afterIdentify = this._hookRunner.identify(context, identifyOptions?.timeout); + const afterIdentify = this._hookRunner.identify(context, effectiveOptions?.timeout); return { context, checkedContext, @@ -363,7 +455,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { identifyResolve, identifyReject, checkedContext, - identifyOptions, + effectiveOptions, ); return identifyPromise; @@ -378,7 +470,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } }, }, - identifyOptions?.sheddable ?? false, + effectiveOptions?.sheddable ?? false, ) .then((res) => { if (res.status === 'error') { @@ -441,7 +533,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { // If waitForInitialization was previously called, then return the promise with a timeout. // This condition should only be triggered if waitForInitialization was called multiple times. if (this.initializedPromise) { - return this.promiseWithTimeout(this.initializedPromise, timeout); + return this._promiseWithTimeout(this.initializedPromise, timeout); } // Create a new promise for tracking initialization @@ -451,22 +543,18 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { }); } - return this.promiseWithTimeout(this.initializedPromise, timeout); + return this._promiseWithTimeout(this.initializedPromise, timeout); } /** - * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * Apply a timeout promise to a base promise. This is for use with waitForInitialization + * and start. * * @param basePromise The promise to race against a timeout. * @param timeout The timeout in seconds. * @returns A promise that resolves to the initialization result or timeout. - * - * @privateRemarks - * This method is protected because it is used by the browser SDK's `start` method. - * Eventually, the start method will be moved to this common implementation and this method will - * be made private. */ - protected promiseWithTimeout( + private _promiseWithTimeout( basePromise: Promise, timeout: number, ): Promise { diff --git a/packages/shared/sdk-client/src/api/LDStartOptions.ts b/packages/shared/sdk-client/src/api/LDStartOptions.ts new file mode 100644 index 0000000000..7b618d2fb4 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDStartOptions.ts @@ -0,0 +1,20 @@ +import { LDIdentifyOptions } from './LDIdentifyOptions'; +import { LDWaitForInitializationOptions } from './LDWaitForInitialization'; + +export interface LDStartOptions extends LDWaitForInitializationOptions { + /** + * Optional bootstrap data to use for the identify operation. + * If {@link LDIdentifyOptions.bootstrap} is provided in identifyOptions, it takes precedence. + */ + bootstrap?: unknown; + + /** + * Optional identify options to use for the identify operation. + * See {@link LDIdentifyOptions} for more information. + * + * @remarks + * Since the first identify option should never be sheddable, the sheddable option is omitted + * from the interface to avoid confusion. + */ + identifyOptions?: Omit; +} diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 9c78ce1965..de03c367fb 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -10,6 +10,7 @@ export * from './LDIdentifyOptions'; export * from './LDInspection'; export * from './LDIdentifyResult'; export * from './LDPlugin'; +export * from './LDStartOptions'; export * from './LDWaitForInitialization'; export * from './LDContext'; export * from './datasource'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index d82b254dc3..1670e7ef1b 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -26,6 +26,12 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { credentialType: 'clientSideId' | 'mobileKey'; getLegacyStorageKeys?: () => string[]; dataSystemDefaults?: PlatformDataSystemDefaults; + + /** + * When true, the SDK requires `start()` to be called before `identify()`. + * Set this value to `true` to use the new initialization pattern. + */ + requiresStart?: boolean; } export interface Configuration { diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 4beba89b8d..951ddc078c 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -42,6 +42,7 @@ export type { LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationTimeout, + LDStartOptions, LDContext, LDContextStrict, } from './api'; From 9bdb8eb5b58010937e12fa8c38610feba492fcb5 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 7 Apr 2026 11:36:20 -0500 Subject: [PATCH 2/9] chore: bot comment --- packages/sdk/browser/src/BrowserClient.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 6f21cb1c2e..74ce15efcb 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -237,7 +237,10 @@ class BrowserClientImpl extends LDClientImpl { identifyOptions?: LDIdentifyOptions, ): Promise { const res = await super.identifyResult(context, identifyOptions); - this._goalManager?.startTracking(); + // Ensure that we do not start the goal manager if start() is not called. + if (this.startPromise) { + this._goalManager?.startTracking(); + } return res; } From e5eb6e6b8e09be41a3e848b3163a76c8895386b5 Mon Sep 17 00:00:00 2001 From: joker23 <2494686+joker23@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:11:14 -0500 Subject: [PATCH 3/9] Update packages/shared/sdk-client/src/LDClientImpl.ts Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> --- packages/shared/sdk-client/src/LDClientImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index e57e19a086..b367273eef 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -388,7 +388,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { ): Promise { if (this._requiresStart && !this.startPromise) { this.logger.error( - 'Client must be started before it can identify a context, did you forget to call start()?', + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', ); return { status: 'error', error: new Error('Identify called before start') }; } From e667274314666bb51ef2e122f89eeaa225f1e393 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 10 Apr 2026 15:18:29 -0500 Subject: [PATCH 4/9] chore: PR comments --- .../browser/__tests__/BrowserClient.test.ts | 519 +++++++++++++++++- .../__tests__/LDClientImpl.start.test.ts | 2 +- 2 files changed, 506 insertions(+), 15 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index e373ff893e..301c54f16d 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -1,4 +1,8 @@ -import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; +import { + AutoEnvAttributes, + LDLogger, + LDSingleKindContext, +} from '@launchdarkly/js-client-sdk-common'; import { makeClient } from '../src/BrowserClient'; import { makeBasicPlatform } from './BrowserClient.mocks'; @@ -177,7 +181,7 @@ describe('given a mock platform for a BrowserClient', () => { }); }); - it('can use bootstrap data via start()', async () => { + it('can use bootstrap data', async () => { const client = makeClient( 'client-side-id', { kind: 'user', key: 'bob' }, @@ -205,25 +209,471 @@ describe('given a mock platform for a BrowserClient', () => { }); }); - it('returns the same promise when start is called multiple times', async () => { + it('can evaluate flags with bootstrap data before identify completes', async () => { const client = makeClient( 'client-side-id', - { kind: 'user', key: 'user-key' }, + { kind: 'user', key: 'bob' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + }, + platform, + ); + + const identifyPromise = client.start({ + identifyOptions: { + bootstrap: goodBootstrapDataWithReasons, + }, + }); + + const flagValue = client.jsonVariationDetail('json', undefined); + expect(flagValue).toEqual({ + reason: { + kind: 'OFF', + }, + value: ['a', 'b', 'c', 'd'], + variationIndex: 1, + }); + + expect(client.getContext()).toBeUndefined(); + + // Wait for identify to complete + await identifyPromise; + + // Verify that active context is now set + expect(client.getContext()).toEqual({ kind: 'user', key: 'bob' }); + }); + + it('parses bootstrap data when start is called with bootstrap', async () => { + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + }, + platform, + ); + + await client.start({ + identifyOptions: { + bootstrap: goodBootstrapDataWithReasons, + }, + }); + + // Verify that bootstrap data was parsed and flags are available. + expect(client.allFlags().killswitch).toBe(true); + expect(client.allFlags()['string-flag']).toBe('is bob'); + expect(client.allFlags().cat).toBe(false); + }); + + it('uses the latest bootstrap data when identify is called with new bootstrap data', async () => { + const initialBootstrapData = { + 'string-flag': 'is bob', + 'my-boolean-flag': false, + $flagsState: { + 'string-flag': { + variation: 1, + version: 3, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + }, + }, + $valid: true, + }; + + const newBootstrapData = { + 'string-flag': 'is alice', + 'my-boolean-flag': true, + $flagsState: { + 'string-flag': { + variation: 1, + version: 4, + }, + 'my-boolean-flag': { + variation: 0, + version: 12, + }, + }, + $valid: true, + }; + + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + }, + platform, + ); + + await client.start({ + identifyOptions: { + bootstrap: initialBootstrapData, + }, + }); + + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + expect(client.boolVariation('my-boolean-flag', false)).toBe(false); + + await client.identify( + { kind: 'user', key: 'alice' }, + { + bootstrap: newBootstrapData, + }, + ); + + expect(client.stringVariation('string-flag', 'default')).toBe('is alice'); + expect(client.boolVariation('my-boolean-flag', false)).toBe(true); + }); + + it('can shed intermediate identify calls', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); + const promise0 = client.start(); + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); + const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); + const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); + + const [result0, result1, result2, result3] = await Promise.all([ + promise0, + promise1, + promise2, + promise3, + ]); + + expect(result0).toEqual({ status: 'complete' }); + expect(result1).toEqual({ status: 'shed' }); + expect(result2).toEqual({ status: 'shed' }); + expect(result3).toEqual({ status: 'completed' }); + // With events and goals disabled the only fetch calls should be for polling requests. + expect(platform.requests.fetch.mock.calls.length).toBe(2); + }); + + it('calls beforeIdentify in order', async () => { + const order: string[] = []; + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'user-key-0' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + hooks: [ + { + beforeIdentify: (hookContext, data) => { + if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { + order.push((hookContext.context as LDSingleKindContext).key); + } + + return data; + }, + getMetadata: () => ({ + name: 'test-hook', + version: '1.0.0', + }), + }, + ], + }, + platform, + ); + + await client.start(); + + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); + const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); + const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); + + await Promise.all([promise1, promise2, promise3]); + expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); + }); + + it('completes identify calls in order', async () => { + const order: string[] = []; + const client = makeClient( + 'client-side-id', + { key: 'user-key-1', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + hooks: [ + { + afterIdentify: (hookContext, data, result) => { + if (result.status === 'shed') { + return data; + } + if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { + order.push((hookContext.context as LDSingleKindContext).key); + } + + return data; + }, + getMetadata: () => ({ + name: 'test-hook', + version: '1.0.0', + }), + }, + ], + }, + platform, + ); + const promise1 = client.start(); - const promise2 = client.start(); + const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); + const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); - expect(promise1).toBe(promise2); + await Promise.all([promise1, promise2, promise3]); + // user-key-2 is shed, so it is not included in the order + expect(order).toEqual(['user-key-1', 'user-key-3']); + }); - const result = await promise1; - expect(result.status).toBe('complete'); + it('completes awaited identify calls in order without shedding', async () => { + const order: string[] = []; + const client = makeClient( + 'client-side-id', + { key: 'user-key-0', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + hooks: [ + { + afterIdentify: (hookContext, data, result) => { + if (result.status === 'shed') { + return data; + } + if ('kind' in hookContext.context && hookContext.context.kind !== 'multi') { + order.push((hookContext.context as LDSingleKindContext).key); + } + + return data; + }, + getMetadata: () => ({ + name: 'test-hook', + version: '1.0.0', + }), + }, + ], + }, + platform, + ); + + await client.start(); + + const result1 = await client.identify({ key: 'user-key-1', kind: 'user' }); + const result2 = await client.identify({ key: 'user-key-2', kind: 'user' }); + const result3 = await client.identify({ key: 'user-key-3', kind: 'user' }); + + expect(result1.status).toEqual('completed'); + expect(result2.status).toEqual('completed'); + expect(result3.status).toEqual('completed'); + + // user-key-2 is shed, so it is not included in the order + expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); }); - it('cannot call identify before start', async () => { + it('can shed intermediate identify calls without waiting for results', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key-0', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + await client.start(); + + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); + const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); + const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); + + await Promise.all([promise1, promise2, promise3]); + + // With events and goals disabled the only fetch calls should be for polling requests. + expect(platform.requests.fetch.mock.calls.length).toBe(3); + }); + + it('it does not shed non-shedable identify calls', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key-0', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + await client.start(); + + const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }, { sheddable: false }); + const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }, { sheddable: false }); + const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }, { sheddable: false }); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + expect(result1).toEqual({ status: 'completed' }); + expect(result2).toEqual({ status: 'completed' }); + expect(result3).toEqual({ status: 'completed' }); + // With events and goals disabled the only fetch calls should be for polling requests. + expect(platform.requests.fetch.mock.calls.length).toBe(4); + }); + + it('blocks until the client is ready when waitForInitialization is called', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + const waitPromise = client.waitForInitialization({ timeout: 10 }); + const startPromise = client.start(); + + await Promise.all([waitPromise, startPromise]); + + await expect(waitPromise).resolves.toEqual({ status: 'complete' }); + await expect(startPromise).resolves.toEqual({ status: 'complete' }); + }); + + it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => { + jest.useRealTimers(); + + // Create a platform with a delayed fetch response + const delayedPlatform = makeBasicPlatform(); + let resolveFetch: (value: any) => void; + const delayedFetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + // Mock fetch to return a promise that won't resolve until we explicitly resolve it + delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + delayedFetchPromise.then(() => ({})), + ) as any; + + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + delayedPlatform, + ); + + client.start(); + + // Call waitForInitialization with a short timeout (0.1 seconds) + const waitPromise = client.waitForInitialization({ timeout: 0.1 }); + + // Verify that waitForInitialization rejects with a timeout error + await expect(waitPromise).resolves.toEqual({ status: 'timeout' }); + + // Clean up: resolve the fetch to avoid hanging promises and restore fake timers + resolveFetch!({}); + jest.useFakeTimers(); + }); + + it('resolves waitForInitialization with failed status immediately when identify fails', async () => { + const errorPlatform = makeBasicPlatform(); + const identifyError = new Error('Network error'); + + // Mock fetch to reject with an error + errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + Promise.reject(identifyError), + ) as any; + + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + errorPlatform, + ); + + // Call waitForInitialization first - this creates the promise + const waitPromise = client.waitForInitialization({ timeout: 10 }); + + // Start identify which will fail + const identifyPromise = client.start(); + + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + + // Verify that waitForInitialization returns immediately with failed status + await expect(waitPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + }); + + it('resolves waitForInitialization with failed status when identify fails before waitForInitialization is called', async () => { + const errorPlatform = makeBasicPlatform(); + const identifyError = new Error('Network error'); + + // Mock fetch to reject with an error + errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) => + Promise.reject(identifyError), + ) as any; + + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + errorPlatform, + ); + + // Start identify which will fail BEFORE waitForInitialization is called + const identifyPromise = client.start(); + + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + + // Now call waitForInitialization AFTER identify has already failed + // It should return the failed status immediately, not timeout + const waitPromise = client.waitForInitialization({ timeout: 10 }); + + // Verify that waitForInitialization returns immediately with failed status + await expect(waitPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + }); + + it('returns the same promise when start is called multiple times', async () => { const client = makeClient( 'client-side-id', { kind: 'user', key: 'user-key' }, @@ -232,12 +682,25 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const result = await client.identify({ kind: 'user', key: 'new-user-key' }); + // Call start multiple times before it completes + const promise1 = client.start(); + const promise2 = client.start(); + const promise3 = client.start(); - expect(result.status).toBe('error'); - if (result.status === 'error') { - expect(result.error.message).toBe('Identify called before start'); - } + // Verify all promises are the same reference + // The implementation should cache the promise and return the same one + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + expect(promise1).toBe(promise3); + + // Verify all promises resolve to the same value + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + expect(result1.status).toBe('complete'); + + // Verify that only one identify call was made (one for polling) + expect(platform.requests.fetch.mock.calls.length).toBe(1); }); describe('automatic streaming state based on event listeners', () => { @@ -376,6 +839,34 @@ describe('given a mock platform for a BrowserClient', () => { }); }); + it('cannot call identify before start', async () => { + const client = makeClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + // Call identify before start + const result = await client.identify({ kind: 'user', key: 'new-user-key' }); + + // Verify that identify returns an error status + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error).toBeInstanceOf(Error); + expect(result.error.message).toBe('Identify called before start'); + } + + // Verify that the logger was called with the error message + expect(logger.error).toHaveBeenCalledWith( + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', + ); + + // Verify that no fetch calls were made + expect(platform.requests.fetch.mock.calls.length).toBe(0); + }); + it('uses FDv1 endpoints when dataSystem is not set', async () => { const client = makeClient( 'client-side-id', diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts index 7f736183e2..ef8f921690 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -238,7 +238,7 @@ describe('LDClientImpl.start()', () => { expect(result.error.message).toBe('Identify called before start'); } expect(logger.error).toHaveBeenCalledWith( - 'Client must be started before it can identify a context, did you forget to call start()?', + 'The client must be started before a context can be identified. Call start() prior to identifying a context.', ); }); From a2cddfd31700d6d09cc5d6dc69d1bd2bc8b02a90 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 13 Apr 2026 12:36:48 -0500 Subject: [PATCH 5/9] chore: PR comments --- .../browser/__tests__/BrowserClient.test.ts | 25 ------------------- .../__tests__/LDClientImpl.start.test.ts | 21 ++++++++++++++++ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 301c54f16d..c7e1bdfcef 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -246,31 +246,6 @@ describe('given a mock platform for a BrowserClient', () => { expect(client.getContext()).toEqual({ kind: 'user', key: 'bob' }); }); - it('parses bootstrap data when start is called with bootstrap', async () => { - const client = makeClient( - 'client-side-id', - { kind: 'user', key: 'bob' }, - AutoEnvAttributes.Disabled, - { - streaming: false, - logger, - diagnosticOptOut: true, - }, - platform, - ); - - await client.start({ - identifyOptions: { - bootstrap: goodBootstrapDataWithReasons, - }, - }); - - // Verify that bootstrap data was parsed and flags are available. - expect(client.allFlags().killswitch).toBe(true); - expect(client.allFlags()['string-flag']).toBe('is bob'); - expect(client.allFlags().cat).toBe(false); - }); - it('uses the latest bootstrap data when identify is called with new bootstrap data', async () => { const initialBootstrapData = { 'string-flag': 'is bob', diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts index ef8f921690..ce4ad119a7 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -1,6 +1,7 @@ import { AutoEnvAttributes, clone, Hasher, LDLogger } from '@launchdarkly/js-sdk-common'; import { LDContext } from '../src/api/LDContext'; +import * as bootstrapModule from '../src/flag-manager/bootstrap'; import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import { createBasicPlatform } from './createBasicPlatform'; @@ -223,6 +224,26 @@ describe('LDClientImpl.start()', () => { expect(flags['other-flag']).toBe(true); expect(flags.killswitch).toBeUndefined(); }); + + it('parses bootstrap data only once when using start()', async () => { + const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); + + const mockPlatform = createBasicPlatform(); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); + ldc.setInitialContext(context); + + await ldc.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, + }); + + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith( + expect.anything(), + goodBootstrapDataWithReasons, + ); + + readFlagsFromBootstrapSpy.mockRestore(); + }); }); describe('requiresStart guard', () => { From c6a9c83e7aaab03fb10f46c62a76c2561f460279 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 13 Apr 2026 14:23:15 -0500 Subject: [PATCH 6/9] chore: removing `setInitialContext` --- packages/sdk/browser/src/BrowserClient.ts | 11 ++++- packages/sdk/electron/src/ElectronClient.ts | 2 +- .../__tests__/LDClientImpl.start.test.ts | 47 ++++++++----------- .../shared/sdk-client/src/LDClientImpl.ts | 9 +--- .../src/configuration/Configuration.ts | 6 +++ 5 files changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 74ce15efcb..92777d3003 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -45,6 +45,7 @@ class BrowserClientImpl extends LDClientImpl { constructor( clientSideId: string, + initialContext: LDContext, autoEnvAttributes: AutoEnvAttributes, options: BrowserOptions = {}, overridePlatform?: Platform, @@ -150,6 +151,7 @@ class BrowserClientImpl extends LDClientImpl { internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), credentialType: 'clientSideId', requiresStart: true, + initialContext, }); this.setEventSendingEnabled(true, false); @@ -291,8 +293,13 @@ export function makeClient( options: BrowserOptions = {}, overridePlatform?: Platform, ): LDClient { - const impl = new BrowserClientImpl(clientSideId, autoEnvAttributes, options, overridePlatform); - impl.setInitialContext(initialContext); + const impl = new BrowserClientImpl( + clientSideId, + initialContext, + autoEnvAttributes, + options, + overridePlatform, + ); // Return a PIMPL style implementation. This decouples the interface from the interface of the implementation. // In the future we should consider updating the common SDK code to not use inheritance and instead compose diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index d42cdf1ed1..21c7a4e90d 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -87,6 +87,7 @@ export class ElectronClient extends LDClientImpl { internal.safeGetHooks(logger, _environmentMetadata, validatedElectronOptions.plugins), credentialType: useClientSideId ? 'clientSideId' : 'mobileKey', requiresStart: true, + initialContext, }; const platform = new ElectronPlatform(logger, options); @@ -119,7 +120,6 @@ export class ElectronClient extends LDClientImpl { internalOptions, ); - this.setInitialContext(initialContext); this._plugins = validatedElectronOptions.plugins; this.setEventSendingEnabled(!this.isOffline(), false); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts index ce4ad119a7..a704e453f2 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -22,6 +22,7 @@ function setupClient( requiresStart?: boolean; disableNetwork?: boolean; sendEvents?: boolean; + initialContext?: LDContext; }, ) { const logger = options?.logger ?? { @@ -46,6 +47,7 @@ function setupClient( getImplementationHooks: () => [], credentialType: 'clientSideId', requiresStart: options?.requiresStart ?? true, + initialContext: options?.initialContext, }, ); @@ -97,8 +99,7 @@ describe('LDClientImpl.start()', () => { it('returns the same promise when called multiple times', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); const promise1 = ldc.start(); const promise2 = ldc.start(); @@ -115,8 +116,7 @@ describe('LDClientImpl.start()', () => { it('returns cached result after initialization completes', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); const result1 = await ldc.start(); expect(result1.status).toBe('complete'); @@ -127,8 +127,7 @@ describe('LDClientImpl.start()', () => { it('resolves with complete status on successful identify', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); const result = await ldc.start(); expect(result.status).toBe('complete'); @@ -136,8 +135,7 @@ describe('LDClientImpl.start()', () => { it('sets the active context after start completes', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); expect(ldc.getContext()).toBeUndefined(); await ldc.start(); @@ -147,8 +145,7 @@ describe('LDClientImpl.start()', () => { describe('bootstrap data', () => { it('presets flags from bootstrap in identifyOptions', async () => { const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); await ldc.start({ identifyOptions: { bootstrap: goodBootstrapData }, @@ -162,8 +159,7 @@ describe('LDClientImpl.start()', () => { it('presets flags from top-level bootstrap option', async () => { const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); await ldc.start({ bootstrap: goodBootstrapData }); @@ -174,8 +170,7 @@ describe('LDClientImpl.start()', () => { it('makes flags available synchronously before identify completes', async () => { const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); const startPromise = ldc.start({ identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, @@ -190,8 +185,7 @@ describe('LDClientImpl.start()', () => { it('supports bootstrap data with evaluation reasons', async () => { const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); await ldc.start({ identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, @@ -206,8 +200,7 @@ describe('LDClientImpl.start()', () => { it('prefers identifyOptions.bootstrap over top-level bootstrap', async () => { const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); const differentBootstrap = { 'other-flag': true, @@ -229,8 +222,7 @@ describe('LDClientImpl.start()', () => { const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); const mockPlatform = createBasicPlatform(); - const { ldc } = setupClient(mockPlatform, { disableNetwork: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { disableNetwork: true, initialContext: context }); await ldc.start({ identifyOptions: { bootstrap: goodBootstrapDataWithReasons }, @@ -249,8 +241,10 @@ describe('LDClientImpl.start()', () => { describe('requiresStart guard', () => { it('blocks identify before start when requiresStart is true', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc, logger } = setupClient(mockPlatform, { requiresStart: true }); - ldc.setInitialContext(context); + const { ldc, logger } = setupClient(mockPlatform, { + requiresStart: true, + initialContext: context, + }); const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' }); @@ -265,8 +259,7 @@ describe('LDClientImpl.start()', () => { it('allows identify after start when requiresStart is true', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform, { requiresStart: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context }); await ldc.start(); @@ -284,8 +277,7 @@ describe('LDClientImpl.start()', () => { it('defaults sheddable to true for post-start identifies when requiresStart is true', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform, { requiresStart: true }); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context }); const startPromise = ldc.start(); const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' }); @@ -324,8 +316,7 @@ describe('LDClientImpl.start()', () => { describe('waitForInitialization integration', () => { it('resolves waitForInitialization when start completes', async () => { const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform); - ldc.setInitialContext(context); + const { ldc } = setupClient(mockPlatform, { initialContext: context }); const waitPromise = ldc.waitForInitialization({ timeout: 10 }); const startPromise = ldc.start(); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index b367273eef..b3158b2a2b 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -129,6 +129,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._config = new ConfigurationImpl(options, internalOptions); this.logger = this._config.logger; this._requiresStart = internalOptions?.requiresStart ?? false; + this.initialContext = internalOptions?.initialContext; this._baseHeaders = defaultHeaders( this.sdkKey, @@ -275,14 +276,6 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { this._flagManager.presetFlags(newFlags); } - /** - * Sets the initial context for the client. This must be called before `start()`. - * @param context The initial context. - */ - setInitialContext(context: LDContext): void { - this.initialContext = context; - } - /** * Starts the client and returns a promise that resolves to the initialization result. * diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 1670e7ef1b..de296ec2bd 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -10,6 +10,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; +import { LDContext } from '../api/LDContext'; import { LDInspection } from '../api/LDInspection'; import type { InternalDataSystemOptions, @@ -32,6 +33,11 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions { * Set this value to `true` to use the new initialization pattern. */ requiresStart?: boolean; + + /** + * The initial context to use when starting the client. + */ + initialContext?: LDContext; } export interface Configuration { From e3c5cc7d96d9eed9e663d41b6e6f811b723c01c2 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 13 Apr 2026 15:14:57 -0500 Subject: [PATCH 7/9] chore: reverting sheddable defaults --- packages/sdk/browser/src/BrowserClient.ts | 6 +++++- packages/sdk/electron/src/ElectronClient.ts | 13 +++++++++++++ .../__tests__/LDClientImpl.start.test.ts | 17 +---------------- packages/shared/sdk-client/src/LDClientImpl.ts | 16 +++++----------- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 92777d3003..a26add5989 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -238,7 +238,11 @@ class BrowserClientImpl extends LDClientImpl { context: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise { - const res = await super.identifyResult(context, identifyOptions); + const options = + identifyOptions?.sheddable === undefined + ? { ...identifyOptions, sheddable: true } + : identifyOptions; + const res = await super.identifyResult(context, options); // Ensure that we do not start the goal manager if start() is not called. if (this.startPromise) { this._goalManager?.startTracking(); diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 21c7a4e90d..dde4dede23 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -17,6 +17,8 @@ import { LDFlagValue, LDHeaders, LDIdentifyOptions, + LDIdentifyResult, + LDLogger, LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, LDWaitForInitializationResult, @@ -136,6 +138,17 @@ export class ElectronClient extends LDClientImpl { internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins); } + override async identifyResult( + context: LDContext, + identifyOptions?: LDIdentifyOptions, + ): Promise { + const options = + identifyOptions?.sheddable === undefined + ? { ...identifyOptions, sheddable: true } + : identifyOptions; + return super.identifyResult(context, options); + } + async setConnectionMode(mode: ConnectionMode): Promise { if (mode === 'offline') { this.setEventSendingEnabled(false, true); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts index a704e453f2..63ce8ba085 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.start.test.ts @@ -275,7 +275,7 @@ describe('LDClientImpl.start()', () => { expect(result.status).toBe('completed'); }); - it('defaults sheddable to true for post-start identifies when requiresStart is true', async () => { + it('allows concurrent identifies after start', async () => { const mockPlatform = setupStreamingPlatform(); const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context }); @@ -292,21 +292,6 @@ describe('LDClientImpl.start()', () => { ]); expect(startResult.status).toBe('complete'); - expect(result1.status).toBe('shed'); - expect(result2.status).toBe('shed'); - expect(result3.status).toBe('completed'); - }); - - it('does not default sheddable when requiresStart is false', async () => { - const mockPlatform = setupStreamingPlatform(); - const { ldc } = setupClient(mockPlatform, { requiresStart: false }); - - const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' }); - const promise2 = ldc.identifyResult({ kind: 'user', key: 'user-2' }); - const promise3 = ldc.identifyResult({ kind: 'user', key: 'user-3' }); - - const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); - expect(result1.status).toBe('completed'); expect(result2.status).toBe('completed'); expect(result3.status).toBe('completed'); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index b3158b2a2b..0acb7ff7ac 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -386,14 +386,8 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { return { status: 'error', error: new Error('Identify called before start') }; } - let effectiveOptions = identifyOptions; - if (this._requiresStart && identifyOptions?.sheddable === undefined) { - effectiveOptions = { ...identifyOptions, sheddable: true }; - } - - const identifyTimeout = effectiveOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS; - const noTimeout = - effectiveOptions?.timeout === undefined && effectiveOptions?.noTimeout === true; + const identifyTimeout = identifyOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS; + const noTimeout = identifyOptions?.timeout === undefined && identifyOptions?.noTimeout === true; // When noTimeout is specified, and a timeout is not specified, then this condition cannot // be encountered. (Our default would need to be greater) @@ -415,7 +409,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } const checkedContext = Context.fromLDContext(context); if (checkedContext.valid) { - const afterIdentify = this._hookRunner.identify(context, effectiveOptions?.timeout); + const afterIdentify = this._hookRunner.identify(context, identifyOptions?.timeout); return { context, checkedContext, @@ -448,7 +442,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { identifyResolve, identifyReject, checkedContext, - effectiveOptions, + identifyOptions, ); return identifyPromise; @@ -463,7 +457,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { } }, }, - effectiveOptions?.sheddable ?? false, + identifyOptions?.sheddable ?? false, ) .then((res) => { if (res.status === 'error') { From 289e6fa80c9d9e1ee37c4d1f6ac16f5766be8fc4 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 16 Apr 2026 12:29:31 -0500 Subject: [PATCH 8/9] chore: bot comment --- packages/shared/sdk-client/src/LDClientImpl.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 0acb7ff7ac..9a94583250 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -331,7 +331,11 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { }); } - this.startPromise = this._promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); + this.startPromise = this._promiseWithTimeout( + this.initializedPromise!, + options?.timeout ?? 5, + 'start', + ); this.identifyResult(this.initialContext!, identifyOptions); return this.startPromise; @@ -544,8 +548,9 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { private _promiseWithTimeout( basePromise: Promise, timeout: number, + label: string = 'waitForInitialization', ): Promise { - const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + const cancelableTimeout = cancelableTimedPromise(timeout, label); return Promise.race([ basePromise.then((res: LDWaitForInitializationResult) => { cancelableTimeout.cancel(); From 5c9e61f90da54f819f108e846a4726f7b04cffc0 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 16 Apr 2026 16:40:08 -0500 Subject: [PATCH 9/9] chore: remove em dash --- packages/shared/sdk-client/src/LDClientImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 9a94583250..caed0f5f74 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -279,7 +279,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { /** * Starts the client and returns a promise that resolves to the initialization result. * - * This method is idempotent — calling it multiple times returns the same promise. + * This method is idempotent - calling it multiple times returns the same promise. * * @param options Optional configuration. See {@link LDStartOptions}. * @returns A promise that resolves to the initialization result.