Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
});
});
});
78 changes: 77 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,73 @@ 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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Stale initialization state persists across multiple identify calls

The _initializeResult state is set when an identify call completes but is never reset when a new identify starts. If a user calls identify successfully, then calls identify again for a different context, and then calls waitForInitialization, the method will immediately return the stale result from the first identify rather than waiting for the in-progress second identify to complete. The initialization state (_initializeResult, _initializedPromise, and _initResolve) needs to be reset at the start of each identifyResult call.

Additional Locations (1)

Fix in Cursor Fix in Web


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

waitForInitialization(
options?: LDWaitForInitializationOptions,
): Promise<LDWaitForInitializationResult> {
const timeout = options?.timeout ?? 5;

// If initialization has already completed (successfully or failed), return the result immediately.
if (this._initializeResult) {
return Promise.resolve(this._initializeResult);
}

// 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._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 +356,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