Skip to content

Commit 156532a

Browse files
authored
feat: implement waitForInitialization for browser sdk 4.x (#1028)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** - **feat: adding `waitForInitialize` to browser 4.x** - **test: adding unit tests for waitForInitilization** **Describe the solution you've provided** Added `waitForInitialization` function to browser 4.x implementation. **Additional context** - I kept the method signiture the same as 3.x but am open to updating it to align with what we have in the server sdks `f({timeout: 5})` instead of `f(5)` - I "simplified" the way we are tracking for the initialization state to a boolean. It looks like our new identification would not be throwing which minimizes the `reject` conditions... I can add those back in case we get an error status returned? At that point we might want to just wait for the timeout to throw. - I put in a default 5 seconds timeout on the wait - I also added in an `initialized` event that a successful identify will emit, I figured that would be a way for developers to know if LDClient initialized even if the initialization goes past timeout. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces waitForInitialization to the browser client with a configurable timeout and explicit success/failed/timeout results, and emits an initialized event on successful identify, with comprehensive tests. > > - **Browser SDK**: > - **Initialization API**: Add `waitForInitialization(options)` to `LDClient` with default 5s timeout, returning `complete | failed | timeout` results; supports multiple calls and immediate return if already resolved. > - **Timeout handling**: Use `cancelableTimedPromise` and internal `_promiseWithTimeout` to race initialization vs timeout. > - **Identify integration**: `identifyResult` now resolves the initialization promise as `complete` or `failed` and starts goal tracking. > - **Events**: Add `"initialized"` event to emitter and emit it on successful identify. > - **Types**: Define `LDWaitForInitializationOptions/Result` types and expose method in public client; wire through `makeClient`. > - **Compat**: > - Exclude `waitForInitialization` from the compat `LDClient` interface. > - **Tests**: > - Add unit tests for `waitForInitialization` covering success, timeout, and failure (before/after calling). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 262c6fe. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9361bd2 commit 156532a

File tree

6 files changed

+300
-3
lines changed

6 files changed

+300
-3
lines changed

packages/sdk/browser/__tests__/BrowserClient.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,132 @@ describe('given a mock platform for a BrowserClient', () => {
373373
// With events and goals disabled the only fetch calls should be for polling requests.
374374
expect(platform.requests.fetch.mock.calls.length).toBe(3);
375375
});
376+
377+
it('blocks until the client is ready when waitForInitialization is called', async () => {
378+
const client = makeClient(
379+
'client-side-id',
380+
AutoEnvAttributes.Disabled,
381+
{ streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false },
382+
platform,
383+
);
384+
385+
const waitPromise = client.waitForInitialization({ timeout: 10 });
386+
const identifyPromise = client.identify({ key: 'user-key', kind: 'user' });
387+
388+
await Promise.all([waitPromise, identifyPromise]);
389+
390+
await expect(waitPromise).resolves.toEqual({ status: 'complete' });
391+
await expect(identifyPromise).resolves.toEqual({ status: 'completed' });
392+
});
393+
394+
it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => {
395+
jest.useRealTimers();
396+
397+
// Create a platform with a delayed fetch response
398+
const delayedPlatform = makeBasicPlatform();
399+
let resolveFetch: (value: any) => void;
400+
const delayedFetchPromise = new Promise((resolve) => {
401+
resolveFetch = resolve;
402+
});
403+
404+
// Mock fetch to return a promise that won't resolve until we explicitly resolve it
405+
delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) =>
406+
delayedFetchPromise.then(() => ({})),
407+
) as any;
408+
409+
const client = makeClient(
410+
'client-side-id',
411+
AutoEnvAttributes.Disabled,
412+
{ streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false },
413+
delayedPlatform,
414+
);
415+
416+
// Start identify which will trigger a fetch that won't complete
417+
client.identify({ key: 'user-key', kind: 'user' });
418+
419+
// Call waitForInitialization with a short timeout (0.1 seconds)
420+
const waitPromise = client.waitForInitialization({ timeout: 0.1 });
421+
422+
// Verify that waitForInitialization rejects with a timeout error
423+
await expect(waitPromise).resolves.toEqual({ status: 'timeout' });
424+
425+
// Clean up: resolve the fetch to avoid hanging promises and restore fake timers
426+
resolveFetch!({});
427+
jest.useFakeTimers();
428+
});
429+
430+
it('resolves waitForInitialization with failed status immediately when identify fails', async () => {
431+
const errorPlatform = makeBasicPlatform();
432+
const identifyError = new Error('Network error');
433+
434+
// Mock fetch to reject with an error
435+
errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) =>
436+
Promise.reject(identifyError),
437+
) as any;
438+
439+
const client = makeClient(
440+
'client-side-id',
441+
AutoEnvAttributes.Disabled,
442+
{ streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false },
443+
errorPlatform,
444+
);
445+
446+
// Call waitForInitialization first - this creates the promise
447+
const waitPromise = client.waitForInitialization({ timeout: 10 });
448+
449+
// Start identify which will fail
450+
const identifyPromise = client.identify({ key: 'user-key', kind: 'user' });
451+
452+
await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries
453+
454+
// Wait for identify to fail
455+
await expect(identifyPromise).resolves.toEqual({
456+
status: 'error',
457+
error: identifyError,
458+
});
459+
460+
// Verify that waitForInitialization returns immediately with failed status
461+
await expect(waitPromise).resolves.toEqual({
462+
status: 'failed',
463+
error: identifyError,
464+
});
465+
});
466+
467+
it('resolves waitForInitialization with failed status when identify fails before waitForInitialization is called', async () => {
468+
const errorPlatform = makeBasicPlatform();
469+
const identifyError = new Error('Network error');
470+
471+
// Mock fetch to reject with an error
472+
errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) =>
473+
Promise.reject(identifyError),
474+
) as any;
475+
476+
const client = makeClient(
477+
'client-side-id',
478+
AutoEnvAttributes.Disabled,
479+
{ streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false },
480+
errorPlatform,
481+
);
482+
483+
// Start identify which will fail BEFORE waitForInitialization is called
484+
const identifyPromise = client.identify({ key: 'user-key', kind: 'user' });
485+
486+
await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries
487+
488+
// Wait for identify to fail
489+
await expect(identifyPromise).resolves.toEqual({
490+
status: 'error',
491+
error: identifyError,
492+
});
493+
494+
// Now call waitForInitialization AFTER identify has already failed
495+
// It should return the failed status immediately, not timeout
496+
const waitPromise = client.waitForInitialization({ timeout: 10 });
497+
498+
// Verify that waitForInitialization returns immediately with failed status
499+
await expect(waitPromise).resolves.toEqual({
500+
status: 'failed',
501+
error: identifyError,
502+
});
503+
});
376504
});

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AutoEnvAttributes,
33
base64UrlEncode,
44
BasicLogger,
5+
cancelableTimedPromise,
56
Configuration,
67
Encoding,
78
FlagManager,
@@ -24,14 +25,24 @@ import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOp
2425
import { registerStateDetection } from './BrowserStateDetector';
2526
import GoalManager from './goals/GoalManager';
2627
import { Goal, isClick } from './goals/Goals';
27-
import { LDClient } from './LDClient';
28+
import {
29+
LDClient,
30+
LDWaitForInitializationComplete,
31+
LDWaitForInitializationFailed,
32+
LDWaitForInitializationOptions,
33+
LDWaitForInitializationResult,
34+
LDWaitForInitializationTimeout,
35+
} from './LDClient';
2836
import { LDPlugin } from './LDPlugin';
2937
import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults } from './options';
3038
import BrowserPlatform from './platform/BrowserPlatform';
3139

3240
class BrowserClientImpl extends LDClientImpl {
3341
private readonly _goalManager?: GoalManager;
3442
private readonly _plugins?: LDPlugin[];
43+
private _initializedPromise?: Promise<LDWaitForInitializationResult>;
44+
private _initResolve?: (result: LDWaitForInitializationResult) => void;
45+
private _initializeResult?: LDWaitForInitializationResult;
3546

3647
constructor(
3748
clientSideId: string,
@@ -212,10 +223,73 @@ class BrowserClientImpl extends LDClientImpl {
212223
identifyOptionsWithUpdatedDefaults.sheddable = true;
213224
}
214225
const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults);
226+
if (res.status === 'completed') {
227+
this._initializeResult = { status: 'complete' };
228+
this._initResolve?.(this._initializeResult);
229+
} else if (res.status === 'error') {
230+
this._initializeResult = { status: 'failed', error: res.error };
231+
this._initResolve?.(this._initializeResult);
232+
}
233+
215234
this._goalManager?.startTracking();
216235
return res;
217236
}
218237

238+
waitForInitialization(
239+
options?: LDWaitForInitializationOptions,
240+
): Promise<LDWaitForInitializationResult> {
241+
const timeout = options?.timeout ?? 5;
242+
243+
// If initialization has already completed (successfully or failed), return the result immediately.
244+
if (this._initializeResult) {
245+
return Promise.resolve(this._initializeResult);
246+
}
247+
248+
// It waitForInitialization was previously called, then return the promise with a timeout.
249+
// This condition should only be triggered if waitForInitialization was called multiple times.
250+
if (this._initializedPromise) {
251+
return this._promiseWithTimeout(this._initializedPromise, timeout);
252+
}
253+
254+
if (!this._initializedPromise) {
255+
this._initializedPromise = new Promise((resolve) => {
256+
this._initResolve = resolve;
257+
});
258+
}
259+
260+
return this._promiseWithTimeout(this._initializedPromise, timeout);
261+
}
262+
263+
/**
264+
* Apply a timeout promise to a base promise. This is for use with waitForInitialization.
265+
*
266+
* @param basePromise The promise to race against a timeout.
267+
* @param timeout The timeout in seconds.
268+
* @param logger A logger to log when the timeout expires.
269+
* @returns
270+
*/
271+
private _promiseWithTimeout(
272+
basePromise: Promise<LDWaitForInitializationResult>,
273+
timeout: number,
274+
): Promise<LDWaitForInitializationResult> {
275+
const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization');
276+
return Promise.race([
277+
basePromise.then((res: LDWaitForInitializationResult) => {
278+
cancelableTimeout.cancel();
279+
return res;
280+
}),
281+
cancelableTimeout.promise
282+
// If the promise resolves without error, then the initialization completed successfully.
283+
// NOTE: this should never return as the resolution would only be triggered by the basePromise
284+
// being resolved.
285+
.then(() => ({ status: 'complete' }) as LDWaitForInitializationComplete)
286+
.catch(() => ({ status: 'timeout' }) as LDWaitForInitializationTimeout),
287+
]).catch((reason) => {
288+
this.logger?.error(reason.message);
289+
return { status: 'failed', error: reason as Error } as LDWaitForInitializationFailed;
290+
});
291+
}
292+
219293
setStreaming(streaming?: boolean): void {
220294
// With FDv2 we may want to consider if we support connection mode directly.
221295
// Maybe with an extension to connection mode for 'automatic'.
@@ -282,6 +356,8 @@ export function makeClient(
282356
close: () => impl.close(),
283357
allFlags: () => impl.allFlags(),
284358
addHook: (hook: Hook) => impl.addHook(hook),
359+
waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) =>
360+
impl.waitForInitialization(waitOptions),
285361
logger: impl.logger,
286362
};
287363

packages/sdk/browser/src/LDClient.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,63 @@ import {
66

77
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
88

9+
/**
10+
* @ignore
11+
* Currently these options and the waitForInitialization method signiture will mirror the one
12+
* that is defined in the server common. We will be consolidating this mehod so that it will
13+
* be common to all sdks in the future.
14+
*/
15+
/**
16+
* Options for the waitForInitialization method.
17+
*/
18+
export interface LDWaitForInitializationOptions {
19+
/**
20+
* The timeout duration in seconds to wait for initialization before resolving the promise.
21+
* If exceeded, the promise will resolve to a {@link LDWaitForInitializationTimeout} object.
22+
*
23+
* If no options are specified on the `waitForInitialization`, the default timeout of 5 seconds will be used.
24+
*
25+
* Using a high timeout, or no timeout, is not recommended because it could result in a long
26+
* delay when conditions prevent successful initialization.
27+
*
28+
* A value of 0 will cause the promise to resolve without waiting. In that scenario it would be
29+
* more effective to not call `waitForInitialization`.
30+
*
31+
* @default 5 seconds
32+
*/
33+
timeout: number;
34+
}
35+
36+
/**
37+
* The waitForInitialization operation failed.
38+
*/
39+
export interface LDWaitForInitializationFailed {
40+
status: 'failed';
41+
error: Error;
42+
}
43+
44+
/**
45+
* The waitForInitialization operation timed out.
46+
*/
47+
export interface LDWaitForInitializationTimeout {
48+
status: 'timeout';
49+
}
50+
51+
/**
52+
* The waitForInitialization operation completed successfully.
53+
*/
54+
export interface LDWaitForInitializationComplete {
55+
status: 'complete';
56+
}
57+
58+
/**
59+
* The result of the waitForInitialization operation.
60+
*/
61+
export type LDWaitForInitializationResult =
62+
| LDWaitForInitializationFailed
63+
| LDWaitForInitializationTimeout
64+
| LDWaitForInitializationComplete;
65+
966
/**
1067
*
1168
* The LaunchDarkly SDK client object.
@@ -66,4 +123,39 @@ export type LDClient = Omit<
66123
pristineContext: LDContext,
67124
identifyOptions?: LDIdentifyOptions,
68125
): Promise<LDIdentifyResult>;
126+
127+
/**
128+
* Returns a Promise that tracks the client's initialization state.
129+
*
130+
* The Promise will be resolved to a {@link LDWaitForInitializationResult} object containing the
131+
* status of the waitForInitialization operation.
132+
*
133+
* @example
134+
* This example shows use of async/await syntax for specifying handlers:
135+
* ```
136+
* const result = await client.waitForInitialization({ timeout: 5 });
137+
*
138+
* if (result.status === 'complete') {
139+
* doSomethingWithSuccessfullyInitializedClient();
140+
* } else if (result.status === 'failed') {
141+
* doSomethingForFailedStartup(result.error);
142+
* } else if (result.status === 'timeout') {
143+
* doSomethingForTimedOutStartup();
144+
* }
145+
* ```
146+
*
147+
* @remarks
148+
* You can also use event listeners ({@link on}) for the same purpose: the event `"initialized"`
149+
* indicates success, and `"error"` indicates an error.
150+
*
151+
* @param options
152+
* Optional configuration. Please see {@link LDWaitForInitializationOptions}.
153+
*
154+
* @returns
155+
* A Promise that will be resolved to a {@link LDWaitForInitializationResult} object containing the
156+
* status of the waitForInitialization operation.
157+
*/
158+
waitForInitialization(
159+
options?: LDWaitForInitializationOptions,
160+
): Promise<LDWaitForInitializationResult>;
69161
};

packages/sdk/browser/src/compat/LDClientCompat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { LDClient as LDCLientBrowser } from '../LDClient';
1414
*/
1515
export interface LDClient extends Omit<
1616
LDCLientBrowser,
17-
'close' | 'flush' | 'identify' | 'identifyResult'
17+
'close' | 'flush' | 'identify' | 'identifyResult' | 'waitForInitialization'
1818
> {
1919
/**
2020
* Identifies a context to LaunchDarkly.

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
345345
if (res.status === 'shed') {
346346
return { status: 'shed' } as LDIdentifyShed;
347347
}
348+
this.emitter.emit('initialized');
348349
return { status: 'completed' } as LDIdentifySuccess;
349350
});
350351

packages/shared/sdk-client/src/LDEmitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type FlagChangeKey = `change:${string}`;
66
* Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used
77
* for specific flag changes.
88
*/
9-
export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error';
9+
export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error' | 'initialized';
1010

1111
/**
1212
* Implementation Note: There should not be any default listeners for change events in a client

0 commit comments

Comments
 (0)