Skip to content
Draft
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
53 changes: 53 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,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();
});
});
62 changes: 62 additions & 0 deletions 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 @@ -15,7 +16,9 @@ import {
LDHeaders,
LDIdentifyResult,
LDPluginEnvironmentMetadata,
LDTimeoutError,
Platform,
safeRegisterDebugOverridePlugins,
} from '@launchdarkly/js-client-sdk-common';

import { getHref } from './BrowserApi';
Expand All @@ -32,6 +35,9 @@ import BrowserPlatform from './platform/BrowserPlatform';
class BrowserClientImpl extends LDClientImpl {
private readonly _goalManager?: GoalManager;
private readonly _plugins?: LDPlugin[];
private _initializedPromise?: Promise<void>;
private _initResolve?: () => void;
private _isInitialized: boolean = false;

constructor(
clientSideId: string,
Expand Down Expand Up @@ -195,6 +201,11 @@ class BrowserClientImpl extends LDClientImpl {
client,
this._plugins || [],
);

const override = this.getDebugOverrides();
if (override) {
safeRegisterDebugOverridePlugins(this.logger, override, this._plugins || []);
}
}

override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void> {
Expand All @@ -212,10 +223,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<void> {
// 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<void>, timeout: number): Promise<void> {
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'.
Expand Down Expand Up @@ -282,6 +343,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,
};

Expand Down
38 changes: 38 additions & 0 deletions packages/sdk/browser/src/LDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,42 @@ 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 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<void>;
};
1 change: 1 addition & 0 deletions packages/sdk/browser/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
LDIdentifyError,
LDIdentifyTimeout,
LDIdentifyShed,
LDDebugOverride,
} from '@launchdarkly/js-client-sdk-common';

/**
Expand Down
Loading