From 9a8ee5b2257dd4756d87ad508a5d7bbc2203eae8 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 3 Dec 2025 15:53:43 -0600 Subject: [PATCH 1/7] feat: adding `waitForInitialize` to browser 4.x --- packages/sdk/browser/src/BrowserClient.ts | 56 +++++++++++++++++++ packages/sdk/browser/src/LDClient.ts | 38 +++++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 1 + packages/shared/sdk-client/src/LDEmitter.ts | 2 +- 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index abb9ceb4c2..aa2d6c2b32 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,6 +2,7 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + cancelableTimedPromise, Configuration, Encoding, FlagManager, @@ -15,6 +16,7 @@ import { LDHeaders, LDIdentifyResult, LDPluginEnvironmentMetadata, + LDTimeoutError, Platform, } from '@launchdarkly/js-client-sdk-common'; @@ -32,6 +34,9 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; + private _initializedPromise?: Promise; + private _initResolve?: () => void; + private _isInitialized: boolean = false; constructor( clientSideId: string, @@ -212,10 +217,60 @@ class BrowserClientImpl extends LDClientImpl { identifyOptionsWithUpdatedDefaults.sheddable = true; } const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); + if (res.status === 'completed') { + this._isInitialized = true; + this._initResolve?.(); + } this._goalManager?.startTracking(); return res; } + waitForInitialization(timeout: number = 5): Promise { + // An initialization promise is only created if someone is going to use that promise. + // If we always created an initialization promise, and there was no call waitForInitialization + // by the time the promise was rejected, then that would result in an unhandled promise + // rejection. + + // It waitForInitialization was previously called, then we can use that promise even if it has + // been resolved or rejected. + if (this._initializedPromise) { + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + if (this._isInitialized) { + return Promise.resolve(); + } + + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve) => { + this._initResolve = resolve; + }); + } + + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + /** + * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * + * @param basePromise The promise to race against a timeout. + * @param timeout The timeout in seconds. + * @param logger A logger to log when the timeout expires. + * @returns + */ + private _promiseWithTimeout(basePromise: Promise, timeout: number): Promise { + const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + return Promise.race([ + basePromise.then(() => cancelableTimeout.cancel()), + cancelableTimeout.promise, + ]).catch((reason) => { + if (reason instanceof LDTimeoutError) { + this.logger?.error(reason.message); + } + throw reason; + }); + } + setStreaming(streaming?: boolean): void { // With FDv2 we may want to consider if we support connection mode directly. // Maybe with an extension to connection mode for 'automatic'. @@ -282,6 +337,7 @@ export function makeClient( close: () => impl.close(), allFlags: () => impl.allFlags(), addHook: (hook: Hook) => impl.addHook(hook), + waitForInitialization: (timeout: number) => impl.waitForInitialization(timeout), logger: impl.logger, }; diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index 6c4f515eef..cef424279a 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -66,4 +66,42 @@ export type LDClient = Omit< pristineContext: LDContext, identifyOptions?: LDIdentifyOptions, ): Promise; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The Promise will be resolved if the client successfully initializes, or rejected if client + * initialization takes longer than the set timeout. + * + * ``` + * // using async/await + * try { + * await client.waitForInitialization(5); + * doSomethingWithSuccessfullyInitializedClient(); + * } catch (err) { + * doSomethingForFailedStartup(err); + * } + * ``` + * + * It is important that you handle the rejection case; otherwise it will become an unhandled Promise + * rejection, which is a serious error on some platforms. The Promise is not created unless you + * request it, so if you never call `waitForInitialization()` then you do not have to worry about + * unhandled rejections. + * + * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * indicates success, and `"error"` indicates an error. + * + * @param timeout + * The amount of time, in seconds, to wait for initialization before rejecting the promise. + * Using a large timeout is not recommended. If you use a large timeout and await it, then + * any network delays will cause your application to wait a long time before + * continuing execution. + * + * @default 5 seconds + * + * @returns + * A Promise that will be resolved if the client initializes successfully, or rejected if it + * fails or the specified timeout elapses. + */ + waitForInitialization(timeout?: number): Promise; }; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 5253275b2e..0e45544483 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -345,6 +345,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult { if (res.status === 'shed') { return { status: 'shed' } as LDIdentifyShed; } + this.emitter.emit('initialized'); return { status: 'completed' } as LDIdentifySuccess; }); diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 76705f90c3..cbd4b4a4d7 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -6,7 +6,7 @@ type FlagChangeKey = `change:${string}`; * Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used * for specific flag changes. */ -export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error'; +export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error' | 'initialized'; /** * Implementation Note: There should not be any default listeners for change events in a client From 817200ba9d2b81939aca7c04ecd23a3ff06a03af Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 3 Dec 2025 16:10:43 -0600 Subject: [PATCH 2/7] test: adding unit tests for waitForInitilization --- .../browser/__tests__/BrowserClient.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 0d06abde70..08011ab1db 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -373,4 +373,57 @@ describe('given a mock platform for a BrowserClient', () => { // With events and goals disabled the only fetch calls should be for polling requests. expect(platform.requests.fetch.mock.calls.length).toBe(3); }); + + it('blocks until the client is ready when waitForInitialization is called', async () => { + const client = makeClient( + 'client-side-id', + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + const waitPromise = client.waitForInitialization(10); + const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + + await Promise.all([waitPromise, identifyPromise]); + + await expect(waitPromise).resolves.toBeUndefined(); + await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); + }); + + it('rejects 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', + 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' }); + + // Call waitForInitialization with a short timeout (0.1 seconds) + const waitPromise = client.waitForInitialization(0.1); + + // Verify that waitForInitialization rejects with a timeout error + await expect(waitPromise).rejects.toThrow(); + + // Clean up: resolve the fetch to avoid hanging promises and restore fake timers + resolveFetch!({}); + jest.useFakeTimers(); + }); }); From acf1ac7fab22686bf999bea751ea63445cbd09c6 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 5 Dec 2025 11:19:52 -0600 Subject: [PATCH 3/7] chore: addressing PR comments --- .../browser/__tests__/BrowserClient.test.ts | 8 +- packages/sdk/browser/src/BrowserClient.ts | 52 ++++++--- packages/sdk/browser/src/LDClient.ts | 102 +++++++++++++----- .../sdk/browser/src/compat/LDClientCompat.ts | 2 +- 4 files changed, 120 insertions(+), 44 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 08011ab1db..af01d1a53c 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -382,12 +382,12 @@ describe('given a mock platform for a BrowserClient', () => { platform, ); - const waitPromise = client.waitForInitialization(10); + const waitPromise = client.waitForInitialization({ timeout: 10 }); const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); await Promise.all([waitPromise, identifyPromise]); - await expect(waitPromise).resolves.toBeUndefined(); + await expect(waitPromise).resolves.toEqual({ status: 'complete' }); await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); }); @@ -417,10 +417,10 @@ describe('given a mock platform for a BrowserClient', () => { client.identify({ key: 'user-key', kind: 'user' }); // Call waitForInitialization with a short timeout (0.1 seconds) - const waitPromise = client.waitForInitialization(0.1); + const waitPromise = client.waitForInitialization({ timeout: 0.1 }); // Verify that waitForInitialization rejects with a timeout error - await expect(waitPromise).rejects.toThrow(); + await expect(waitPromise).resolves.toEqual({ status: 'timeout' }); // Clean up: resolve the fetch to avoid hanging promises and restore fake timers resolveFetch!({}); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index aa2d6c2b32..244116a97f 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -16,7 +16,6 @@ import { LDHeaders, LDIdentifyResult, LDPluginEnvironmentMetadata, - LDTimeoutError, Platform, } from '@launchdarkly/js-client-sdk-common'; @@ -26,7 +25,14 @@ import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOp import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; -import { LDClient } from './LDClient'; +import { + LDClient, + LDWaitForInitializationComplete, + LDWaitForInitializationFailed, + LDWaitForInitializationOptions, + LDWaitForInitializationResult, + LDWaitForInitializationTimeout, +} from './LDClient'; import { LDPlugin } from './LDPlugin'; import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; @@ -34,8 +40,8 @@ import BrowserPlatform from './platform/BrowserPlatform'; class BrowserClientImpl extends LDClientImpl { private readonly _goalManager?: GoalManager; private readonly _plugins?: LDPlugin[]; - private _initializedPromise?: Promise; - private _initResolve?: () => void; + private _initializedPromise?: Promise; + private _initResolve?: (result: LDWaitForInitializationResult) => void; private _isInitialized: boolean = false; constructor( @@ -219,13 +225,17 @@ class BrowserClientImpl extends LDClientImpl { const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); if (res.status === 'completed') { this._isInitialized = true; - this._initResolve?.(); + this._initResolve?.({ status: 'complete' }); } this._goalManager?.startTracking(); return res; } - waitForInitialization(timeout: number = 5): Promise { + waitForInitialization( + options?: LDWaitForInitializationOptions, + ): Promise { + const timeout = options?.timeout ?? 5; + // An initialization promise is only created if someone is going to use that promise. // If we always created an initialization promise, and there was no call waitForInitialization // by the time the promise was rejected, then that would result in an unhandled promise @@ -238,7 +248,9 @@ class BrowserClientImpl extends LDClientImpl { } if (this._isInitialized) { - return Promise.resolve(); + return Promise.resolve({ + status: 'complete', + }); } if (!this._initializedPromise) { @@ -258,16 +270,25 @@ class BrowserClientImpl extends LDClientImpl { * @param logger A logger to log when the timeout expires. * @returns */ - private _promiseWithTimeout(basePromise: Promise, timeout: number): Promise { + private _promiseWithTimeout( + basePromise: Promise, + timeout: number, + ): Promise { const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); return Promise.race([ - basePromise.then(() => cancelableTimeout.cancel()), - cancelableTimeout.promise, + basePromise.then((res: LDWaitForInitializationResult) => { + cancelableTimeout.cancel(); + return res; + }), + cancelableTimeout.promise + // If the promise resolves without error, then the initialization completed successfully. + // NOTE: this should never return as the resolution would only be triggered by the basePromise + // being resolved. + .then(() => ({ status: 'complete' }) as LDWaitForInitializationComplete) + .catch(() => ({ status: 'timeout' }) as LDWaitForInitializationTimeout), ]).catch((reason) => { - if (reason instanceof LDTimeoutError) { - this.logger?.error(reason.message); - } - throw reason; + this.logger?.error(reason.message); + return { status: 'failed', error: reason as Error } as LDWaitForInitializationFailed; }); } @@ -337,7 +358,8 @@ export function makeClient( close: () => impl.close(), allFlags: () => impl.allFlags(), addHook: (hook: Hook) => impl.addHook(hook), - waitForInitialization: (timeout: number) => impl.waitForInitialization(timeout), + waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) => + impl.waitForInitialization(waitOptions), logger: impl.logger, }; diff --git a/packages/sdk/browser/src/LDClient.ts b/packages/sdk/browser/src/LDClient.ts index cef424279a..3480a75911 100644 --- a/packages/sdk/browser/src/LDClient.ts +++ b/packages/sdk/browser/src/LDClient.ts @@ -6,6 +6,63 @@ import { import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; +/** + * @ignore + * Currently these options and the waitForInitialization method signiture will mirror the one + * that is defined in the server common. We will be consolidating this mehod so that it will + * be common to all sdks in the future. + */ +/** + * Options for the waitForInitialization method. + */ +export interface LDWaitForInitializationOptions { + /** + * The timeout duration in seconds to wait for initialization before resolving the promise. + * If exceeded, the promise will resolve to a {@link LDWaitForInitializationTimeout} object. + * + * If no options are specified on the `waitForInitialization`, the default timeout of 5 seconds will be used. + * + * Using a high timeout, or no timeout, is not recommended because it could result in a long + * delay when conditions prevent successful initialization. + * + * A value of 0 will cause the promise to resolve without waiting. In that scenario it would be + * more effective to not call `waitForInitialization`. + * + * @default 5 seconds + */ + timeout: number; +} + +/** + * The waitForInitialization operation failed. + */ +export interface LDWaitForInitializationFailed { + status: 'failed'; + error: Error; +} + +/** + * The waitForInitialization operation timed out. + */ +export interface LDWaitForInitializationTimeout { + status: 'timeout'; +} + +/** + * The waitForInitialization operation completed successfully. + */ +export interface LDWaitForInitializationComplete { + status: 'complete'; +} + +/** + * The result of the waitForInitialization operation. + */ +export type LDWaitForInitializationResult = + | LDWaitForInitializationFailed + | LDWaitForInitializationTimeout + | LDWaitForInitializationComplete; + /** * * The LaunchDarkly SDK client object. @@ -70,38 +127,35 @@ export type LDClient = Omit< /** * Returns a Promise that tracks the client's initialization state. * - * The Promise will be resolved if the client successfully initializes, or rejected if client - * initialization takes longer than the set timeout. + * The Promise will be resolved to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. * + * @example + * This example shows use of async/await syntax for specifying handlers: * ``` - * // using async/await - * try { - * await client.waitForInitialization(5); - * doSomethingWithSuccessfullyInitializedClient(); - * } catch (err) { - * doSomethingForFailedStartup(err); + * const result = await client.waitForInitialization({ timeout: 5 }); + * + * if (result.status === 'complete') { + * doSomethingWithSuccessfullyInitializedClient(); + * } else if (result.status === 'failed') { + * doSomethingForFailedStartup(result.error); + * } else if (result.status === 'timeout') { + * doSomethingForTimedOutStartup(); * } * ``` * - * It is important that you handle the rejection case; otherwise it will become an unhandled Promise - * rejection, which is a serious error on some platforms. The Promise is not created unless you - * request it, so if you never call `waitForInitialization()` then you do not have to worry about - * unhandled rejections. - * - * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * @remarks + * You can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` * indicates success, and `"error"` indicates an error. * - * @param timeout - * The amount of time, in seconds, to wait for initialization before rejecting the promise. - * Using a large timeout is not recommended. If you use a large timeout and await it, then - * any network delays will cause your application to wait a long time before - * continuing execution. - * - * @default 5 seconds + * @param options + * Optional configuration. Please see {@link LDWaitForInitializationOptions}. * * @returns - * A Promise that will be resolved if the client initializes successfully, or rejected if it - * fails or the specified timeout elapses. + * A Promise that will be resolved to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. */ - waitForInitialization(timeout?: number): Promise; + waitForInitialization( + options?: LDWaitForInitializationOptions, + ): Promise; }; diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts index ab88392725..21fc1c0c81 100644 --- a/packages/sdk/browser/src/compat/LDClientCompat.ts +++ b/packages/sdk/browser/src/compat/LDClientCompat.ts @@ -14,7 +14,7 @@ import { LDClient as LDCLientBrowser } from '../LDClient'; */ export interface LDClient extends Omit< LDCLientBrowser, - 'close' | 'flush' | 'identify' | 'identifyResult' + 'close' | 'flush' | 'identify' | 'identifyResult' | 'waitForInitialization' > { /** * Identifies a context to LaunchDarkly. From 8c3d52cc0402ccf9fd32acaa77a1f889ced9a811 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 9 Dec 2025 12:11:56 -0600 Subject: [PATCH 4/7] chore: addressing PR comments --- packages/sdk/browser/src/BrowserClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 244116a97f..6a4d3b6874 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -226,7 +226,10 @@ class BrowserClientImpl extends LDClientImpl { if (res.status === 'completed') { this._isInitialized = true; this._initResolve?.({ status: 'complete' }); + } else if (res.status === 'error') { + this._initResolve?.({ status: 'failed', error: res.error }); } + this._goalManager?.startTracking(); return res; } From a80589f15e9bdbbd2cd5e233fb3a446abb517c4e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 9 Dec 2025 13:10:44 -0600 Subject: [PATCH 5/7] test: adding test for identify fail --- .../browser/__tests__/BrowserClient.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index af01d1a53c..9514704f32 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -391,7 +391,7 @@ describe('given a mock platform for a BrowserClient', () => { await expect(identifyPromise).resolves.toEqual({ status: 'completed' }); }); - it('rejects when initialization does not complete before the timeout', async () => { + 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 @@ -426,4 +426,39 @@ describe('given a mock platform for a BrowserClient', () => { 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', + 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.identify({ key: 'user-key', kind: 'user' }); + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'error', + error: identifyError, + }); + + // Verify that waitForInitialization returns immediately with failed status + await expect(waitPromise).resolves.toEqual({ + status: 'failed', + error: identifyError, + }); + }); }); From 0d640bfb71fcefa8b26e9db670f8975b897a15a0 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 10 Dec 2025 13:22:22 -0600 Subject: [PATCH 6/7] test: fix unit tests unit tests broke because we introduced retries on initial polls which timed out of initial wait tests (since those tests were using fake timers). This change would simply advance the fake timer so all retries would have been triggered. --- packages/sdk/browser/__tests__/BrowserClient.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 9514704f32..282c9c9127 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -449,6 +449,8 @@ describe('given a mock platform for a BrowserClient', () => { // Start identify which will fail const identifyPromise = client.identify({ key: 'user-key', kind: 'user' }); + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + // Wait for identify to fail await expect(identifyPromise).resolves.toEqual({ status: 'error', From 4c64b0dbaff5614213ac6cb0b6b6795e00ebe3cd Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 10 Dec 2025 14:42:57 -0600 Subject: [PATCH 7/7] test: add unit test for handling identify failure before initialization This commit will also fix this issue --- .../browser/__tests__/BrowserClient.test.ts | 38 +++++++++++++++++++ packages/sdk/browser/src/BrowserClient.ts | 16 ++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 282c9c9127..bd6a7e3cae 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -463,4 +463,42 @@ describe('given a mock platform for a BrowserClient', () => { 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', + 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' }); + + await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries + + // Wait for identify to fail + await expect(identifyPromise).resolves.toEqual({ + status: 'error', + 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, + }); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 6a4d3b6874..28ddf73cd4 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -42,7 +42,7 @@ class BrowserClientImpl extends LDClientImpl { private readonly _plugins?: LDPlugin[]; private _initializedPromise?: Promise; private _initResolve?: (result: LDWaitForInitializationResult) => void; - private _isInitialized: boolean = false; + private _initializeResult?: LDWaitForInitializationResult; constructor( clientSideId: string, @@ -224,10 +224,11 @@ class BrowserClientImpl extends LDClientImpl { } const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults); if (res.status === 'completed') { - this._isInitialized = true; - this._initResolve?.({ status: 'complete' }); + this._initializeResult = { status: 'complete' }; + this._initResolve?.(this._initializeResult); } else if (res.status === 'error') { - this._initResolve?.({ status: 'failed', error: res.error }); + this._initializeResult = { status: 'failed', error: res.error }; + this._initResolve?.(this._initializeResult); } this._goalManager?.startTracking(); @@ -250,10 +251,9 @@ class BrowserClientImpl extends LDClientImpl { return this._promiseWithTimeout(this._initializedPromise, timeout); } - if (this._isInitialized) { - return Promise.resolve({ - status: 'complete', - }); + // If initialization has already completed (successfully or failed), return the result immediately + if (this._initializeResult) { + return Promise.resolve(this._initializeResult); } if (!this._initializedPromise) {