Skip to content
Open
128 changes: 128 additions & 0 deletions packages/sdk/browser/__tests__/BrowserClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,132 @@ 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({ timeout: 10 });
const identifyPromise = client.identify({ key: 'user-key', kind: 'user' });

await Promise.all([waitPromise, identifyPromise]);

await expect(waitPromise).resolves.toEqual({ status: 'complete' });
await expect(identifyPromise).resolves.toEqual({ status: 'completed' });
});

it('resolves waitForInitialization with timeout status when initialization does not complete before the timeout', async () => {
jest.useRealTimers();

// Create a platform with a delayed fetch response
const delayedPlatform = makeBasicPlatform();
let resolveFetch: (value: any) => void;
const delayedFetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});

// Mock fetch to return a promise that won't resolve until we explicitly resolve it
delayedPlatform.requests.fetch = jest.fn((_url: string, _options: any) =>
delayedFetchPromise.then(() => ({})),
) as any;

const client = makeClient(
'client-side-id',
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({ timeout: 0.1 });

// Verify that waitForInitialization rejects with a timeout error
await expect(waitPromise).resolves.toEqual({ status: 'timeout' });

// Clean up: resolve the fetch to avoid hanging promises and restore fake timers
resolveFetch!({});
jest.useFakeTimers();
});

it('resolves waitForInitialization with failed status immediately when identify fails', async () => {
const errorPlatform = makeBasicPlatform();
const identifyError = new Error('Network error');

// Mock fetch to reject with an error
errorPlatform.requests.fetch = jest.fn((_url: string, _options: any) =>
Promise.reject(identifyError),
) as any;

const client = makeClient(
'client-side-id',
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' });

await jest.advanceTimersByTimeAsync(4000); // trigger all poll retries

// 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,
});
});

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,
});
});
});
83 changes: 82 additions & 1 deletion packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AutoEnvAttributes,
base64UrlEncode,
BasicLogger,
cancelableTimedPromise,
Configuration,
Encoding,
FlagManager,
Expand All @@ -24,14 +25,24 @@ 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';

class BrowserClientImpl extends LDClientImpl {
private readonly _goalManager?: GoalManager;
private readonly _plugins?: LDPlugin[];
private _initializedPromise?: Promise<LDWaitForInitializationResult>;
private _initResolve?: (result: LDWaitForInitializationResult) => void;
private _initializeResult?: LDWaitForInitializationResult;

constructor(
clientSideId: string,
Expand Down Expand Up @@ -212,10 +223,78 @@ class BrowserClientImpl extends LDClientImpl {
identifyOptionsWithUpdatedDefaults.sheddable = true;
}
const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults);
if (res.status === 'completed') {
this._initializeResult = { status: 'complete' };
this._initResolve?.(this._initializeResult);
} else if (res.status === 'error') {
this._initializeResult = { status: 'failed', error: res.error };
this._initResolve?.(this._initializeResult);
}

this._goalManager?.startTracking();
return res;
}

waitForInitialization(
options?: LDWaitForInitializationOptions,
): Promise<LDWaitForInitializationResult> {
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
// 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 initialization has already completed (successfully or failed), return the result immediately
if (this._initializeResult) {
return Promise.resolve(this._initializeResult);
}

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<LDWaitForInitializationResult>,
timeout: number,
): Promise<LDWaitForInitializationResult> {
const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization');
return Promise.race([
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) => {
this.logger?.error(reason.message);
return { status: 'failed', error: reason as Error } as LDWaitForInitializationFailed;
});
}

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'.
Expand Down Expand Up @@ -282,6 +361,8 @@ export function makeClient(
close: () => impl.close(),
allFlags: () => impl.allFlags(),
addHook: (hook: Hook) => impl.addHook(hook),
waitForInitialization: (waitOptions?: LDWaitForInitializationOptions) =>
impl.waitForInitialization(waitOptions),
logger: impl.logger,
};

Expand Down
92 changes: 92 additions & 0 deletions packages/sdk/browser/src/LDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,4 +123,39 @@ export type LDClient = Omit<
pristineContext: LDContext,
identifyOptions?: LDIdentifyOptions,
): Promise<LDIdentifyResult>;

/**
* Returns a Promise that tracks the client's initialization state.
*
* 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:
* ```
* 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();
* }
* ```
*
* @remarks
* You can also use event listeners ({@link on}) for the same purpose: the event `"initialized"`
* indicates success, and `"error"` indicates an error.
*
* @param options
* Optional configuration. Please see {@link LDWaitForInitializationOptions}.
*
* @returns
* A Promise that will be resolved to a {@link LDWaitForInitializationResult} object containing the
* status of the waitForInitialization operation.
*/
waitForInitialization(
options?: LDWaitForInitializationOptions,
): Promise<LDWaitForInitializationResult>;
};
2 changes: 1 addition & 1 deletion packages/sdk/browser/src/compat/LDClientCompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
2 changes: 1 addition & 1 deletion packages/shared/sdk-client/src/LDEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down