From 7001f977d314eef82f8623c96d066fbbc5865f3d Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 16 Dec 2025 14:16:10 -0600 Subject: [PATCH 01/10] refactor!: update BrowserClient initialization and context handling - Introduced `start` method in `BrowserClient` to handle client initialization with context. - Replaced direct calls to `identify` with `setInitialContext` and `start` for better context management. - Updated example app to reflect changes in client initialization and context handling. - Added tests to ensure proper functionality of new initialization flow. --- .../browser/__tests__/BrowserClient.test.ts | 19 +++-- .../compat/LDClientCompatImpl.test.ts | 1 + packages/sdk/browser/example/src/app.ts | 25 ++++-- packages/sdk/browser/src/BrowserClient.ts | 78 +++++++++++++------ packages/sdk/browser/src/LDClient.ts | 31 +++++++- .../sdk/browser/src/compat/LDClientCompat.ts | 8 +- packages/sdk/browser/src/compat/index.ts | 6 +- packages/sdk/browser/src/index.ts | 16 +++- 8 files changed, 140 insertions(+), 44 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 089d19286e..49eebca3f4 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -173,12 +173,14 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify( - { kind: 'user', key: 'bob' }, - { + + client.setInitialContext({ kind: 'user', key: 'bob' }); + + await client.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons, }, - ); + }); expect(client.jsonVariationDetail('json', undefined)).toEqual({ reason: { @@ -201,12 +203,13 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const identifyPromise = client.identify( - { kind: 'user', key: 'bob' }, - { + client.setInitialContext({ kind: 'user', key: 'bob' }); + + const identifyPromise = client.start({ + identifyOptions: { bootstrap: goodBootstrapDataWithReasons, }, - ); + }); const flagValue = client.jsonVariationDetail('json', undefined); expect(flagValue).toEqual({ diff --git a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts b/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts index ac6368189f..c38aa20fca 100644 --- a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts +++ b/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts @@ -36,6 +36,7 @@ const mockBrowserClient: jest.MockedObject = { error: jest.fn(), }, getContext: jest.fn(), + start: jest.fn(), }; jest.mock('../../src/BrowserClient', () => ({ diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 8ca290132f..5992f22e6d 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -24,7 +24,7 @@ div.appendChild(document.createTextNode('No flag evaluations yet')); statusBox.appendChild(document.createTextNode('Initializing...')); const main = async () => { - const ldclient = initialize(clientSideID); + const ldclient = initialize(clientSideID, context); const render = () => { const flagValue = ldclient.variation(flagKey, false); const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; @@ -44,15 +44,30 @@ const main = async () => { render(); }); - const { status } = await ldclient.identify(context); + ldclient.start(); - if (status === 'completed') { - statusBox.replaceChild(document.createTextNode('Initialized'), statusBox.firstChild as Node); - } else if (status === 'error') { + const { status } = await ldclient.waitForInitialization(); + + if (status === 'complete') { + statusBox.replaceChild( + document.createTextNode(`Initialized with context: ${JSON.stringify(ldclient.getContext())}`), + statusBox.firstChild as Node, + ); + } else if (status === 'failed') { statusBox.replaceChild( document.createTextNode('Error identifying client'), statusBox.firstChild as Node, ); + } else if (status === 'timeout') { + statusBox.replaceChild( + document.createTextNode('Timeout identifying client'), + statusBox.firstChild as Node, + ); + } else { + statusBox.replaceChild( + document.createTextNode('Unknown error identifying client'), + statusBox.firstChild as Node, + ); } render(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index e765a71fa3..000730c0e0 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -29,6 +29,7 @@ import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import { LDClient, + LDStartOptions, LDWaitForInitializationComplete, LDWaitForInitializationFailed, LDWaitForInitializationOptions, @@ -42,13 +43,14 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; - private _initializedPromise?: Promise; + private _waitForInitializedPromise?: Promise; private _initResolve?: (result: LDWaitForInitializationResult) => void; private _initializeResult?: LDWaitForInitializationResult; - // NOTE: keeps track of when we tried an initial identification. We should consolidate this - // with the waitForInitialization logic in the future. - private _identifyAttempted: boolean = false; + private _initialContext?: LDContext; + + // NOTE: This also keeps track of when we tried to initialize the client. + private _initializePromise?: Promise; constructor( clientSideId: string, @@ -219,6 +221,47 @@ class BrowserClientImpl extends LDClientImpl { } } + setInitialContext(context: LDContext): void { + this._initialContext = context; + } + + async start(options?: LDStartOptions): Promise { + if (this._initializeResult) { + return this._initializeResult; + } + if (this._initializePromise) { + return this._initializePromise; + } + if (!this._initialContext) { + this.logger.error('Initial context not set'); + return { status: 'failed', error: new Error('Initial context not set') }; + } + + const identifyOptions = options?.identifyOptions ?? {}; + + if (identifyOptions?.bootstrap) { + try { + const bootstrapData = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap); + this.presetFlags(bootstrapData); + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + this._initializePromise = new Promise((resolve) => { + this.identifyResult(this._initialContext!, identifyOptions).then((result) => { + if (result.status === 'timeout') { + resolve({ status: 'timeout' }); + } else if (result.status === 'error') { + resolve({ status: 'failed', error: result.error }); + } + resolve({ status: 'complete' }); + }); + }); + + return this._promiseWithTimeout(this._initializePromise, options?.timeout ?? 5); + } + override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { return super.identify(context, identifyOptions); } @@ -234,21 +277,6 @@ class BrowserClientImpl extends LDClientImpl { identifyOptionsWithUpdatedDefaults.sheddable = true; } - if (!this._identifyAttempted) { - this._identifyAttempted = true; - if (identifyOptionsWithUpdatedDefaults.bootstrap) { - try { - const bootstrapData = readFlagsFromBootstrap( - this.logger, - identifyOptionsWithUpdatedDefaults.bootstrap, - ); - this.presetFlags(bootstrapData); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - } - const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); if (res.status === 'completed') { this._initializeResult = { status: 'complete' }; @@ -274,17 +302,17 @@ class BrowserClientImpl extends LDClientImpl { // It 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); + if (this._waitForInitializedPromise) { + return this._promiseWithTimeout(this._waitForInitializedPromise, timeout); } - if (!this._initializedPromise) { - this._initializedPromise = new Promise((resolve) => { + if (!this._waitForInitializedPromise) { + this._waitForInitializedPromise = new Promise((resolve) => { this._initResolve = resolve; }); } - return this._promiseWithTimeout(this._initializedPromise, timeout); + return this._promiseWithTimeout(this._waitForInitializedPromise, timeout); } /** @@ -386,6 +414,8 @@ export function makeClient( waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) => impl.waitForInitialization(waitOptions), logger: impl.logger, + setInitialContext: (context: LDContext) => impl.setInitialContext(context), + start: (startOptions?: LDStartOptions) => impl.start(startOptions), }; impl.registerPlugins(client); diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 3480a75911..0df4fd8580 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -30,7 +30,7 @@ export interface LDWaitForInitializationOptions { * * @default 5 seconds */ - timeout: number; + timeout?: number; } /** @@ -63,6 +63,13 @@ export type LDWaitForInitializationResult = | LDWaitForInitializationTimeout | LDWaitForInitializationComplete; +export interface LDStartOptions extends LDWaitForInitializationOptions { + /** + * Optional identify options to use for the identify operation. {@link LDIdentifyOptions} + */ + identifyOptions?: LDIdentifyOptions; +} + /** * * The LaunchDarkly SDK client object. @@ -158,4 +165,26 @@ export type LDClient = Omit< waitForInitialization( options?: LDWaitForInitializationOptions, ): Promise; + + /** + * Starts the client and returns a promise that resolves to the initialization result. + * + * The promise will resolve to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. + * + * @param options Optional configuration. Please see {@link LDStartOptions}. + */ + start(options?: LDStartOptions): Promise; + + /** + * Sets the initial context for the client. + * + * The initial context is the context that was used to initialize the client. It is used to identify the client to LaunchDarkly. + * + * This method should only be called once, and should be called before the client is used. It is used to set the initial context for the client. + * + * @param context + * The LDContext object. + */ + setInitialContext(context: LDContext): void; }; diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts index 21fc1c0c81..48e792cd22 100644 --- a/packages/sdk/browser/src/compat/LDClientCompat.ts +++ b/packages/sdk/browser/src/compat/LDClientCompat.ts @@ -14,7 +14,13 @@ import { LDClient as LDCLientBrowser } from '../LDClient'; */ export interface LDClient extends Omit< LDCLientBrowser, - 'close' | 'flush' | 'identify' | 'identifyResult' | 'waitForInitialization' + | 'close' + | 'flush' + | 'identify' + | 'identifyResult' + | 'waitForInitialization' + | 'setInitialContext' + | 'start' > { /** * Identifies a context to LaunchDarkly. diff --git a/packages/sdk/browser/src/compat/index.ts b/packages/sdk/browser/src/compat/index.ts index f913d7d01f..10068e70e8 100644 --- a/packages/sdk/browser/src/compat/index.ts +++ b/packages/sdk/browser/src/compat/index.ts @@ -49,6 +49,10 @@ export type { LDClient, LDOptions }; * @return * The new client instance. */ -export function initialize(envKey: string, context: LDContext, options?: LDOptions): LDClient { +export function initialize( + envKey: string, + context: LDContext, + options?: LDOptions, +): Omit { return new LDClientCompatImpl(envKey, context, options); } diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index bf91ae34a7..b4d45afcc6 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -10,7 +10,7 @@ * * @packageDocumentation */ -import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; +import { AutoEnvAttributes, LDContext } from '@launchdarkly/js-client-sdk-common'; import { makeClient } from './BrowserClient'; import { LDClient } from './LDClient'; @@ -26,7 +26,8 @@ export type { LDPlugin } from './LDPlugin'; * Usage: * ``` * import { initialize } from 'launchdarkly-js-client-sdk'; - * const client = initialize(clientSideId, options); + * const client = initialize(clientSideId, context, options); + * * ``` * * @param clientSideId @@ -36,7 +37,14 @@ export type { LDPlugin } from './LDPlugin'; * @return * The new client instance. */ -export function initialize(clientSideId: string, options?: LDOptions): LDClient { +export function initialize( + clientSideId: string, + pristineContext: LDContext, + options?: LDOptions, +): LDClient { // AutoEnvAttributes are not supported yet in the browser SDK. - return makeClient(clientSideId, AutoEnvAttributes.Disabled, options); + const client = makeClient(clientSideId, AutoEnvAttributes.Disabled, options); + client.setInitialContext(pristineContext); + + return client; } From 7f63565c63735c1ac34e778253700a8077029e60 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 16 Dec 2025 14:36:51 -0600 Subject: [PATCH 02/10] test: add unit test for BrowserClient start method promise caching - Implemented a test to verify that multiple calls to the `start` method of `BrowserClient` return the same promise and resolve to the same result. - Ensured that only one identify call is made during the initialization process, confirming the promise caching behavior. - Fix contract tests to use the new initialization method --- .../browser/__tests__/BrowserClient.test.ts | 31 +++++++++++++++++++ .../contract-tests/entity/src/ClientEntity.ts | 4 +-- packages/sdk/browser/src/BrowserClient.ts | 11 ++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 49eebca3f4..d910d72786 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -541,4 +541,35 @@ describe('given a mock platform for a BrowserClient', () => { error: identifyError, }); }); + + it('returns the same promise when start is called multiple times', async () => { + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + client.setInitialContext({ kind: 'user', key: 'user-key' }); + + // 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'); + + // Verify that only one identify call was made (one for polling) + expect(platform.requests.fetch.mock.calls.length).toBe(1); + }); }); diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 27d0415068..096cad25e4 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -207,11 +207,11 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = initialize(options.configuration.credential || 'unknown-env-id', sdkConfig); + const client = initialize(options.configuration.credential || 'unknown-env-id', initialContext, sdkConfig); let failed = false; try { await Promise.race([ - client.identify(initialContext), + client.start(), new Promise((_resolve, reject) => { setTimeout(reject, timeout); }), diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 000730c0e0..a32dc83468 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -225,16 +225,16 @@ class BrowserClientImpl extends LDClientImpl { this._initialContext = context; } - async start(options?: LDStartOptions): Promise { + start(options?: LDStartOptions): Promise { if (this._initializeResult) { - return this._initializeResult; + return Promise.resolve(this._initializeResult); } if (this._initializePromise) { return this._initializePromise; } if (!this._initialContext) { this.logger.error('Initial context not set'); - return { status: 'failed', error: new Error('Initial context not set') }; + return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); } const identifyOptions = options?.identifyOptions ?? {}; @@ -248,7 +248,7 @@ class BrowserClientImpl extends LDClientImpl { } } - this._initializePromise = new Promise((resolve) => { + const identifyPromise = new Promise((resolve) => { this.identifyResult(this._initialContext!, identifyOptions).then((result) => { if (result.status === 'timeout') { resolve({ status: 'timeout' }); @@ -259,7 +259,8 @@ class BrowserClientImpl extends LDClientImpl { }); }); - return this._promiseWithTimeout(this._initializePromise, options?.timeout ?? 5); + this._initializePromise = this._promiseWithTimeout(identifyPromise, options?.timeout ?? 5); + return this._initializePromise; } override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { From 5ab236265e731aa5df28aa2dc005b6874ab6cf33 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 16 Dec 2025 15:18:54 -0600 Subject: [PATCH 03/10] chore: modify the wait logic to track initialization and start separately --- .../contract-tests/entity/src/ClientEntity.ts | 6 +- packages/sdk/browser/src/BrowserClient.ts | 93 ++++++++++--------- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 096cad25e4..9cf9bc9a0e 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -207,7 +207,11 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = initialize(options.configuration.credential || 'unknown-env-id', initialContext, sdkConfig); + const client = initialize( + options.configuration.credential || 'unknown-env-id', + initialContext, + sdkConfig, + ); let failed = false; try { await Promise.race([ diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a32dc83468..8c6aabc5ca 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -43,14 +43,18 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; - private _waitForInitializedPromise?: Promise; + + // The initialized promise is used to track the initialization state of the client. + // This is separate from the start promise because the start promise could time out before + // the initialization is complete. + private _initializedPromise?: Promise; private _initResolve?: (result: LDWaitForInitializationResult) => void; private _initializeResult?: LDWaitForInitializationResult; private _initialContext?: LDContext; // NOTE: This also keeps track of when we tried to initialize the client. - private _initializePromise?: Promise; + private _startPromise?: Promise; constructor( clientSideId: string, @@ -225,44 +229,6 @@ class BrowserClientImpl extends LDClientImpl { this._initialContext = context; } - start(options?: LDStartOptions): Promise { - if (this._initializeResult) { - return Promise.resolve(this._initializeResult); - } - if (this._initializePromise) { - return this._initializePromise; - } - if (!this._initialContext) { - this.logger.error('Initial context not set'); - return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); - } - - const identifyOptions = options?.identifyOptions ?? {}; - - if (identifyOptions?.bootstrap) { - try { - const bootstrapData = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap); - this.presetFlags(bootstrapData); - } catch (error) { - this.logger.error('Failed to bootstrap data', error); - } - } - - const identifyPromise = new Promise((resolve) => { - this.identifyResult(this._initialContext!, identifyOptions).then((result) => { - if (result.status === 'timeout') { - resolve({ status: 'timeout' }); - } else if (result.status === 'error') { - resolve({ status: 'failed', error: result.error }); - } - resolve({ status: 'complete' }); - }); - }); - - this._initializePromise = this._promiseWithTimeout(identifyPromise, options?.timeout ?? 5); - return this._initializePromise; - } - override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { return super.identify(context, identifyOptions); } @@ -291,6 +257,43 @@ class BrowserClientImpl extends LDClientImpl { 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 ?? {}; + + if (identifyOptions?.bootstrap) { + try { + const bootstrapData = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap); + this.presetFlags(bootstrapData); + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve) => { + this._initResolve = resolve; + }); + } + + this.identifyResult(this._initialContext!, identifyOptions); + + this._startPromise = this._promiseWithTimeout(this._initializedPromise, options?.timeout ?? 5); + return this._startPromise; + } + waitForInitialization( options?: LDWaitForInitializationOptions, ): Promise { @@ -303,17 +306,17 @@ class BrowserClientImpl extends LDClientImpl { // It 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._waitForInitializedPromise) { - return this._promiseWithTimeout(this._waitForInitializedPromise, timeout); + if (this._initializedPromise) { + return this._promiseWithTimeout(this._initializedPromise, timeout); } - if (!this._waitForInitializedPromise) { - this._waitForInitializedPromise = new Promise((resolve) => { + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve) => { this._initResolve = resolve; }); } - return this._promiseWithTimeout(this._waitForInitializedPromise, timeout); + return this._promiseWithTimeout(this._initializedPromise, timeout); } /** From 36d62def81d827a4a2a000e9f62f07862b829e2a Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 16 Dec 2025 17:03:15 -0600 Subject: [PATCH 04/10] refactor: removing `setInitialContext` from public interface --- .../__tests__/BrowserClient.plugins.test.ts | 12 ++-- .../browser/__tests__/BrowserClient.test.ts | 61 ++++++++++++------- packages/sdk/browser/src/BrowserClient.ts | 3 +- packages/sdk/browser/src/LDClient.ts | 12 ---- .../browser/src/compat/LDClientCompatImpl.ts | 2 +- packages/sdk/browser/src/index.ts | 3 +- 6 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts b/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts index bc4c8e4e1e..60bbef86a5 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.plugins.test.ts @@ -41,8 +41,10 @@ it('registers plugins and executes hooks during initialization', async () => { const platform = makeBasicPlatform(); + const context: LDContext = { key: 'user-key', kind: 'user' }; const client = makeClient( 'client-side-id', + context, AutoEnvAttributes.Disabled, { streaming: false, @@ -55,10 +57,7 @@ it('registers plugins and executes hooks during initialization', async () => { // Verify the plugin was registered expect(mockPlugin.register).toHaveBeenCalled(); - - // Now test that hooks work by calling identify and variation - const context: LDContext = { key: 'user-key', kind: 'user' }; - await client.identify(context); + await client.start(); expect(mockHook.beforeIdentify).toHaveBeenCalledWith({ context, timeout: undefined }, {}); @@ -134,6 +133,7 @@ it('registers multiple plugins and executes all hooks', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -149,7 +149,7 @@ it('registers multiple plugins and executes all hooks', async () => { expect(mockPlugin2.register).toHaveBeenCalled(); // Test that both hooks work - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); client.variation('flag-key', false); client.track('event-key', { data: true }, 42); @@ -200,6 +200,7 @@ it('passes correct environmentMetadata to plugin getHooks and register functions makeClient( 'client-side-id', + { kind: 'user', key: '', anonymous: true }, AutoEnvAttributes.Disabled, { streaming: false, @@ -281,6 +282,7 @@ it('passes correct environmentMetadata without optional fields', async () => { makeClient( 'client-side-id', + { kind: 'user', key: '', anonymous: true }, AutoEnvAttributes.Disabled, { streaming: false, diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index d910d72786..d058543408 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -29,6 +29,7 @@ describe('given a mock platform for a BrowserClient', () => { it('includes urls in custom events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -37,7 +38,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); client.track('user-key', undefined, 1); await client.flush(); @@ -58,6 +59,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in custom events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -68,7 +70,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); client.track('user-key', undefined, 1); await client.flush(); @@ -92,6 +94,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in click events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -102,7 +105,7 @@ describe('given a mock platform for a BrowserClient', () => { }, platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); // Simulate a click event @@ -135,6 +138,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can filter URLs in pageview events', async () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -146,7 +150,7 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - await client.identify({ key: 'user-key', kind: 'user' }); + await client.start(); await client.flush(); const events = JSON.parse(platform.requests.fetch.mock.calls[2][1].body); @@ -165,6 +169,7 @@ describe('given a mock platform for a BrowserClient', () => { it('can use bootstrap data', async () => { const client = makeClient( 'client-side-id', + { kind: 'user', key: 'bob' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -174,8 +179,6 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - client.setInitialContext({ kind: 'user', key: 'bob' }); - await client.start({ identifyOptions: { bootstrap: goodBootstrapDataWithReasons, @@ -194,6 +197,7 @@ 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, @@ -203,8 +207,6 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - client.setInitialContext({ kind: 'user', key: 'bob' }); - const identifyPromise = client.start({ identifyOptions: { bootstrap: goodBootstrapDataWithReasons, @@ -232,18 +234,26 @@ describe('given a mock platform for a BrowserClient', () => { 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 [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + const [result0, result1, result2, result3] = await Promise.all([ + promise0, + promise1, + promise2, + promise3, + ]); - expect(result1).toEqual({ status: 'completed' }); + 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. @@ -254,6 +264,7 @@ describe('given a mock platform for a BrowserClient', () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { kind: 'user', key: 'user-key-0' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -292,6 +303,7 @@ describe('given a mock platform for a BrowserClient', () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { key: 'user-key-1', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -321,7 +333,7 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const promise1 = client.identify({ key: 'user-key-1', kind: 'user' }); + const promise1 = client.start(); const promise2 = client.identify({ key: 'user-key-2', kind: 'user' }); const promise3 = client.identify({ key: 'user-key-3', kind: 'user' }); @@ -334,6 +346,7 @@ describe('given a mock platform for a BrowserClient', () => { const order: string[] = []; const client = makeClient( 'client-side-id', + { key: 'user-key-0', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, @@ -378,6 +391,7 @@ describe('given a mock platform for a BrowserClient', () => { 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, @@ -396,6 +410,7 @@ describe('given a mock platform for a BrowserClient', () => { 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, @@ -417,18 +432,19 @@ describe('given a mock platform for a BrowserClient', () => { 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 identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + const startPromise = client.start(); - await Promise.all([waitPromise, identifyPromise]); + await Promise.all([waitPromise, startPromise]); await expect(waitPromise).resolves.toEqual({ status: 'complete' }); - await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); + await expect(startPromise).resolves.toEqual({ status: 'complete' }); }); it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => { @@ -448,13 +464,13 @@ describe('given a mock platform for a BrowserClient', () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, delayedPlatform, ); - // Start identify which will trigger a fetch that won't complete - client.identify({ key: 'user-key', kind: 'user' }); + client.start(); // Call waitForInitialization with a short timeout (0.1 seconds) const waitPromise = client.waitForInitialization({ timeout: 0.1 }); @@ -478,6 +494,7 @@ describe('given a mock platform for a BrowserClient', () => { const client = makeClient( 'client-side-id', + { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, errorPlatform, @@ -487,13 +504,13 @@ describe('given a mock platform for a BrowserClient', () => { const waitPromise = client.waitForInitialization({ timeout: 10 }); // Start identify which will fail - const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + const identifyPromise = client.start(); await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries // Wait for identify to fail await expect(identifyPromise).resolves.toEqual({ - status: 'error', + status: 'failed', error: identifyError, }); @@ -515,19 +532,20 @@ describe('given a mock platform for a BrowserClient', () => { 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.identify({ key: 'user-key', kind: 'user' }); + const identifyPromise = client.start(); await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries // Wait for identify to fail await expect(identifyPromise).resolves.toEqual({ - status: 'error', + status: 'failed', error: identifyError, }); @@ -545,13 +563,12 @@ describe('given a mock platform for a BrowserClient', () => { it('returns the same promise when start is called multiple times', async () => { const client = makeClient( 'client-side-id', + { kind: 'user', key: 'user-key' }, AutoEnvAttributes.Disabled, { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, platform, ); - client.setInitialContext({ kind: 'user', key: 'user-key' }); - // Call start multiple times before it completes const promise1 = client.start(); const promise2 = client.start(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 8c6aabc5ca..4551d2b6e3 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -376,11 +376,13 @@ class BrowserClientImpl extends LDClientImpl { export function makeClient( clientSideId: string, + initialContext: LDContext, autoEnvAttributes: AutoEnvAttributes, options: BrowserOptions = {}, overridePlatform?: Platform, ): LDClient { const impl = new BrowserClientImpl(clientSideId, autoEnvAttributes, options, overridePlatform); + impl.setInitialContext(initialContext); // 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 @@ -418,7 +420,6 @@ export function makeClient( waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) => impl.waitForInitialization(waitOptions), logger: impl.logger, - setInitialContext: (context: LDContext) => impl.setInitialContext(context), start: (startOptions?: LDStartOptions) => impl.start(startOptions), }; diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 0df4fd8580..441869b3da 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -175,16 +175,4 @@ export type LDClient = Omit< * @param options Optional configuration. Please see {@link LDStartOptions}. */ start(options?: LDStartOptions): Promise; - - /** - * Sets the initial context for the client. - * - * The initial context is the context that was used to initialize the client. It is used to identify the client to LaunchDarkly. - * - * This method should only be called once, and should be called before the client is used. It is used to set the initial context for the client. - * - * @param context - * The LDContext object. - */ - setInitialContext(context: LDContext): void; }; diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts index 946207a8be..3c0451603d 100644 --- a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts +++ b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts @@ -42,7 +42,7 @@ export default class LDClientCompatImpl implements LDClient { const cleanedOptions = { ...options }; delete cleanedOptions.bootstrap; delete cleanedOptions.hash; - this._client = makeClient(envKey, AutoEnvAttributes.Disabled, options); + this._client = makeClient(envKey, context, AutoEnvAttributes.Disabled, options); this._emitter = new LDEmitterCompat(this._client); this.logger = this._client.logger; this._initIdentify(context, bootstrap, hash); diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index b4d45afcc6..519e2c3290 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -43,8 +43,7 @@ export function initialize( options?: LDOptions, ): LDClient { // AutoEnvAttributes are not supported yet in the browser SDK. - const client = makeClient(clientSideId, AutoEnvAttributes.Disabled, options); - client.setInitialContext(pristineContext); + const client = makeClient(clientSideId, pristineContext, AutoEnvAttributes.Disabled, options); return client; } From b0dc5676cb039cb1d50f62fdf11d8c493d9f1799 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 17 Dec 2025 11:15:14 -0600 Subject: [PATCH 05/10] chore: addressing PR comments - require `start` to be called before additional `identify` --- .../browser/__tests__/BrowserClient.test.ts | 44 +++++++++++++++++-- packages/sdk/browser/src/BrowserClient.ts | 11 ++++- packages/sdk/browser/src/index.ts | 19 ++++++-- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index d058543408..af9b61c25e 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -291,12 +291,14 @@ describe('given a mock platform for a BrowserClient', () => { 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-1', 'user-key-2', 'user-key-3']); + expect(order).toEqual(['user-key-0', 'user-key-1', 'user-key-2', 'user-key-3']); }); it('completes identify calls in order', async () => { @@ -376,6 +378,8 @@ describe('given a mock platform for a BrowserClient', () => { 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' }); @@ -385,7 +389,7 @@ describe('given a mock platform for a BrowserClient', () => { expect(result3.status).toEqual('completed'); // user-key-2 is shed, so it is not included in the order - expect(order).toEqual(['user-key-1', 'user-key-2', 'user-key-3']); + 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 () => { @@ -397,6 +401,8 @@ describe('given a mock platform for a BrowserClient', () => { 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' }); @@ -404,7 +410,7 @@ describe('given a mock platform for a BrowserClient', () => { 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(2); + expect(platform.requests.fetch.mock.calls.length).toBe(3); }); it('it does not shed non-shedable identify calls', async () => { @@ -416,6 +422,8 @@ describe('given a mock platform for a BrowserClient', () => { 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 }); @@ -426,7 +434,7 @@ describe('given a mock platform for a BrowserClient', () => { 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(3); + expect(platform.requests.fetch.mock.calls.length).toBe(4); }); it('blocks until the client is ready when waitForInitialization is called', async () => { @@ -589,4 +597,32 @@ describe('given a mock platform for a BrowserClient', () => { // Verify that only one identify call was made (one for polling) expect(platform.requests.fetch.mock.calls.length).toBe(1); }); + + 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); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 4551d2b6e3..1ea0316d81 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -237,6 +237,13 @@ 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, }; @@ -288,9 +295,9 @@ class BrowserClientImpl extends LDClientImpl { }); } - this.identifyResult(this._initialContext!, identifyOptions); - this._startPromise = this._promiseWithTimeout(this._initializedPromise, options?.timeout ?? 5); + + this.identifyResult(this._initialContext!, identifyOptions); return this._startPromise; } diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 519e2c3290..497f340655 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -21,21 +21,32 @@ export type { LDClient, LDOptions }; export type { LDPlugin } from './LDPlugin'; /** - * Creates an instance of the LaunchDarkly client. + * Creates an instance of the LaunchDarkly client. Note that the client will not be ready to + * use until {@link LDClient.start} is called. * * Usage: * ``` * import { initialize } from 'launchdarkly-js-client-sdk'; * const client = initialize(clientSideId, context, options); * + * // Attach event listeners and add any additional logic here + * + * // Then start the client + * client.start(); * ``` + * @remarks + * The client will not automatically start until {@link LDClient.start} is called in order to + * synchronize the registering of event listeners and other initialization logic that should be + * done before the client initiates its connection to LaunchDarkly. * * @param clientSideId * The client-side ID, also known as the environment ID. + * @param pristineContext + * The initial context used to identify the user. @see {@link LDContext} * @param options - * Optional configuration settings. - * @return - * The new client instance. + * Optional configuration settings. @see {@link LDOptions} + * @returns + * The new client instance. @see {@link LDClient} */ export function initialize( clientSideId: string, From 7994552df4a4238a151e61341e020e71a379ebeb Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 17 Dec 2025 14:14:19 -0600 Subject: [PATCH 06/10] fix: browser compat breaks due to start call being required --- .../browser/src/compat/LDClientCompatImpl.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts index 3c0451603d..ec79f0b865 100644 --- a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts +++ b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts @@ -45,23 +45,20 @@ export default class LDClientCompatImpl implements LDClient { this._client = makeClient(envKey, context, AutoEnvAttributes.Disabled, options); this._emitter = new LDEmitterCompat(this._client); this.logger = this._client.logger; - this._initIdentify(context, bootstrap, hash); + this._initIdentify(bootstrap, hash); } - private async _initIdentify( - context: LDContext, - bootstrap?: LDFlagSet, - hash?: string, - ): Promise { + private async _initIdentify(bootstrap?: LDFlagSet, hash?: string): Promise { try { - const result = await this._client.identify(context, { - noTimeout: true, - bootstrap, - hash, - sheddable: false, + const result = await this._client.start({ + identifyOptions: { + noTimeout: true, + bootstrap, + hash, + }, }); - if (result.status === 'error') { + if (result.status === 'failed') { throw result.error; } else if (result.status === 'timeout') { throw new LDTimeoutError('Identify timed out'); From 97e98560a22b07f6314eb72ebf4db62b40ca34aa Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 17 Dec 2025 15:07:06 -0600 Subject: [PATCH 07/10] chore: fixing issues with rebase --- .../browser/src/compat/LDClientCompatImpl.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts index ec79f0b865..e10721d249 100644 --- a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts +++ b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts @@ -45,20 +45,27 @@ export default class LDClientCompatImpl implements LDClient { this._client = makeClient(envKey, context, AutoEnvAttributes.Disabled, options); this._emitter = new LDEmitterCompat(this._client); this.logger = this._client.logger; - this._initIdentify(bootstrap, hash); + + // start the client, then immediately kick off an identify operation + // in order to preserve the behavior of the previous SDK. + this._client.start(); + this._initIdentify(context, bootstrap, hash); } - private async _initIdentify(bootstrap?: LDFlagSet, hash?: string): Promise { + private async _initIdentify( + context: LDContext, + bootstrap?: LDFlagSet, + hash?: string, + ): Promise { try { - const result = await this._client.start({ - identifyOptions: { - noTimeout: true, - bootstrap, - hash, - }, + const result = await this._client.identify(context, { + noTimeout: true, + bootstrap, + hash, + sheddable: false, }); - if (result.status === 'failed') { + if (result.status === 'error') { throw result.error; } else if (result.status === 'timeout') { throw new LDTimeoutError('Identify timed out'); From d43dae29cb00e6d125011b50e1bbf26c8760ed14 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 18 Dec 2025 09:28:33 -0600 Subject: [PATCH 08/10] refactor: add optional bootstrap props to startOptions - Added logic to use bootstrap data from start options if not provided in identify options. - Updated LDStartOptions interface to include an optional bootstrap property for identify operations. --- packages/sdk/browser/src/BrowserClient.ts | 6 ++++++ packages/sdk/browser/src/LDClient.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 1ea0316d81..0f38a02741 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -280,6 +280,12 @@ class BrowserClientImpl extends LDClientImpl { // attempted. This line should only be called once during the lifetime of the client. const identifyOptions = options?.identifyOptions ?? {}; + // 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 { const bootstrapData = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap); diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 441869b3da..f0cbaec891 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -64,6 +64,11 @@ export type LDWaitForInitializationResult = | LDWaitForInitializationComplete; 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. {@link LDIdentifyOptions} */ From 2c87e55d763cc4220c98139b5504d7352c3c6b8c Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 18 Dec 2025 14:26:42 -0600 Subject: [PATCH 09/10] chore: rename initialize function To align the api to more of what it does. --- .../sdk/browser/contract-tests/entity/src/ClientEntity.ts | 4 ++-- packages/sdk/browser/example/src/app.ts | 4 ++-- packages/sdk/browser/src/compat/index.ts | 2 +- packages/sdk/browser/src/index.ts | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 9cf9bc9a0e..0d50a90bb8 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,4 +1,4 @@ -import { initialize, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; +import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; import { CommandParams, CommandType, ValueType } from './CommandParams'; import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; @@ -207,7 +207,7 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { options.configuration.clientSide?.initialUser || options.configuration.clientSide?.initialContext || makeDefaultInitialContext(); - const client = initialize( + const client = createClient( options.configuration.credential || 'unknown-env-id', initialContext, sdkConfig, diff --git a/packages/sdk/browser/example/src/app.ts b/packages/sdk/browser/example/src/app.ts index 5992f22e6d..2e9e21c0d9 100644 --- a/packages/sdk/browser/example/src/app.ts +++ b/packages/sdk/browser/example/src/app.ts @@ -1,4 +1,4 @@ -import { initialize } from '@launchdarkly/js-client-sdk'; +import { createClient } from '@launchdarkly/js-client-sdk'; // Set clientSideID to your LaunchDarkly client-side ID const clientSideID = 'LD_CLIENT_SIDE_ID'; @@ -24,7 +24,7 @@ div.appendChild(document.createTextNode('No flag evaluations yet')); statusBox.appendChild(document.createTextNode('Initializing...')); const main = async () => { - const ldclient = initialize(clientSideID, context); + const ldclient = createClient(clientSideID, context); const render = () => { const flagValue = ldclient.variation(flagKey, false); const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; diff --git a/packages/sdk/browser/src/compat/index.ts b/packages/sdk/browser/src/compat/index.ts index 10068e70e8..d5dbb5a9f3 100644 --- a/packages/sdk/browser/src/compat/index.ts +++ b/packages/sdk/browser/src/compat/index.ts @@ -16,7 +16,7 @@ export type { LDClient, LDOptions }; /** * Creates an instance of the LaunchDarkly client. This version of initialization is for - * improved backwards compatibility. In general the `initialize` function from the root packge + * improved backwards compatibility. In general the `createClient` function from the root packge * should be used instead of the one in the `/compat` module. * * The client will begin attempting to connect to LaunchDarkly as soon as it is created. To diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 497f340655..cc744bf0a2 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -3,7 +3,7 @@ * * This SDK is intended for use in browser environments. * - * In typical usage, you will call {@link initialize} once at startup time to obtain an instance of + * In typical usage, you will call {@link createClient} once at startup time to obtain an instance of * {@link LDClient}, which provides access to all of the SDK's functionality. * * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript). @@ -26,8 +26,8 @@ export type { LDPlugin } from './LDPlugin'; * * Usage: * ``` - * import { initialize } from 'launchdarkly-js-client-sdk'; - * const client = initialize(clientSideId, context, options); + * import { createClient } from 'launchdarkly-js-client-sdk'; + * const client = createClient(clientSideId, context, options); * * // Attach event listeners and add any additional logic here * @@ -48,7 +48,7 @@ export type { LDPlugin } from './LDPlugin'; * @returns * The new client instance. @see {@link LDClient} */ -export function initialize( +export function createClient( clientSideId: string, pristineContext: LDContext, options?: LDOptions, From 846ae61108c3e6cf5753f08ac6813679d47772d4 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 18 Dec 2025 15:05:53 -0600 Subject: [PATCH 10/10] chore: ensure that the first identify operation is not sheddable --- packages/sdk/browser/src/BrowserClient.ts | 7 ++++++- packages/sdk/browser/src/LDClient.ts | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 0f38a02741..96e1904c37 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -278,7 +278,12 @@ class BrowserClientImpl extends LDClientImpl { // 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 ?? {}; + 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. diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index f0cbaec891..4dfe8cfd53 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -70,9 +70,12 @@ export interface LDStartOptions extends LDWaitForInitializationOptions { bootstrap?: unknown; /** - * Optional identify options to use for the identify operation. {@link LDIdentifyOptions} + * 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. */ - identifyOptions?: LDIdentifyOptions; + identifyOptions?: Omit; } /**