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
11 changes: 3 additions & 8 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,19 +230,15 @@ class BrowserClientImpl extends LDClientImpl {
}
}

override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void> {
return super.identify(context, identifyOptions);
}

override async identifyResult(
override async identify(
context: LDContext,
identifyOptions?: LDIdentifyOptions,
): Promise<LDIdentifyResult> {
const options =
identifyOptions?.sheddable === undefined
? { ...identifyOptions, sheddable: true }
: identifyOptions;
const res = await super.identifyResult(context, options);
const res = await super.identify(context, options);
// Ensure that we do not start the goal manager if start() is not called.
if (this.startPromise) {
this._goalManager?.startTracking();
Expand Down Expand Up @@ -308,7 +304,6 @@ export function makeClient(
// Return a PIMPL style implementation. This decouples the interface from the interface of the implementation.
// In the future we should consider updating the common SDK code to not use inheritance and instead compose
// the leaf-implementation.
// The purpose for this in the short-term is to have a signature for identify that is different than the class implementation.
// Using an object with PIMPL here also allows us to completely hide the underlying implementation, where with a class
// it is trivial to access what should be protected (or even private) fields.
const client: LDClient = {
Expand All @@ -334,7 +329,7 @@ export function makeClient(
setConnectionMode: (mode?: FDv2ConnectionMode) => impl.setConnectionMode(mode),
setStreaming: (streaming?: boolean) => impl.setStreaming(streaming),
identify: (pristineContext: LDContext, identifyOptions?: LDIdentifyOptions) =>
impl.identifyResult(pristineContext, identifyOptions),
impl.identify(pristineContext, identifyOptions),
getContext: () => impl.getContext(),
close: () => impl.close(),
allFlags: () => impl.allFlags(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('given an initialized ElectronClient', () => {
const context: LDContext = { kind: 'user', key: 'test-user-id' };
const options: LDIdentifyOptions = { waitForNetworkResults: true };

const spy = jest.spyOn(client, 'identifyResult');
const spy = jest.spyOn(client, 'identify');
spy.mockResolvedValueOnce({ status: 'completed' });

const result = await mockIpcMain.getHandler(getEventName('identify'))?.({}, context, options);
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/electron/src/ElectronClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,15 @@ export class ElectronClient extends LDClientImpl {
internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins);
}

override async identifyResult(
override async identify(
context: LDContext,
identifyOptions?: LDIdentifyOptions,
): Promise<LDIdentifyResult> {
const options =
identifyOptions?.sheddable === undefined
? { ...identifyOptions, sheddable: true }
: identifyOptions;
return super.identifyResult(context, options);
return super.identify(context, options);
}

async setConnectionMode(mode: ConnectionMode): Promise<void> {
Expand Down Expand Up @@ -223,7 +223,7 @@ export class ElectronClient extends LDClientImpl {
});

ipcMain.handle(getIPCChannelName(namespace, 'identify'), (_event, context, identifyOptions) =>
this.identifyResult(context, identifyOptions),
this.identify(context, identifyOptions),
);

ipcMain.on(getIPCChannelName(namespace, 'log'), (_event, level: string, message: string) => {
Expand Down Expand Up @@ -434,7 +434,7 @@ export function makeClient(
impl.off(key as LDEmitterEventName, callback as (...args: unknown[]) => void),
flush: () => impl.flush(),
identify: (ctx: LDContext, identifyOptions?: LDIdentifyOptions) =>
impl.identifyResult(ctx, identifyOptions),
impl.identify(ctx, identifyOptions),
getContext: () => impl.getContext(),
close: () => impl.close(),
allFlags: () => impl.allFlags(),
Expand Down
38 changes: 38 additions & 0 deletions packages/sdk/react-native/src/LDClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
LDClient as CommonClient,
LDContext,
LDIdentifyOptions,
} from '@launchdarkly/js-client-sdk-common';

/**
* React Native LDClient type. Overrides `identify()` to return `Promise<void>` and throw on
* error/timeout to maintain backward compatibility with existing React Native consumers.
*/
export type LDClient = Omit<CommonClient, 'identify'> & {
/**
* Identifies a context to LaunchDarkly.
*
* Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state,
* which is set when you call `identify()`.
*
* Changing the current context also causes all feature flag values to be reloaded. Until that has
* finished, calls to {@link variation} will still return flag values for the previous context. You can
* await the Promise to determine when the new flag values are available.
*
* @param context
* The LDContext object.
* @param identifyOptions
* Optional configuration. Please see {@link LDIdentifyOptions}.
* @returns
* A Promise which resolves when the flag values for the specified
* context are available. It rejects when:
*
* 1. The context is unspecified or has no key.
*
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
*
* 3. A network error is encountered during initialization.
*/
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;
};
32 changes: 32 additions & 0 deletions packages/sdk/react-native/src/ReactNativeLDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import {
type LDClientDataSystemOptions,
LDClientImpl,
LDClientInternalOptions,
type LDContext,
LDEmitter,
LDHeaders,
type LDIdentifyOptions,
type LDIdentifyResult,
LDPluginEnvironmentMetadata,
LDTimeoutError,
type LDWaitForInitializationResult,
MOBILE_DATA_SYSTEM_DEFAULTS,
MOBILE_TRANSITION_TABLE,
mobileFdv1Endpoints,
Expand Down Expand Up @@ -227,6 +232,33 @@ export default class ReactNativeLDClient extends LDClientImpl {
);
}

override async identify(
context: LDContext,
identifyOptions?: LDIdentifyOptions,
): Promise<LDIdentifyResult> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think should be okay because I don't think anyone is checking for void returns?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It doesn't make me particularly comfortable to actually be returning something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yea, I don't think we could really do this step comfortably until we major version RN... which is fine... I'll move this back to draft or close, otherwise the changes seem fairly trivial.

const result = await super.identify(context, identifyOptions);
if (result.status === 'error') {
throw result.error;
} else if (result.status === 'timeout') {
const timeoutError = new LDTimeoutError(
`identify timed out after ${result.timeout} seconds.`,
);
this.logger.error(timeoutError.message);
throw timeoutError;
}
return result;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

RN identify rejects despite interface promising no rejection

Medium Severity

ReactNativeLDClient.identify() throws on error and timeout statuses, but the base LDClient interface now explicitly documents that "The promise returned from this method will not be rejected." The RN constructor passes this directly to internal.safeRegisterPlugins, so plugins receive a client whose identify can reject — violating the contract they rely on. A plugin calling identify without a try-catch (reasonable given the new contract) risks unhandled promise rejections in the React Native SDK specifically.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b88b805. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional

}

/**
* Protect against trying to use start() in RN.
*
* @internal
**/

override start(): Promise<LDWaitForInitializationResult> {
throw new Error('start() is not supported in the React Native SDK. Use identify() directly.');
}

override async close(): Promise<void> {
this._stateDetector?.stopListening();
this._connectionManager?.close();
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import RNOptions, { RNDataSystemOptions, RNStorage } from './RNOptions';

export * from '@launchdarkly/js-client-sdk-common';

// Override the common LDClient type with a React Native-specific one that
// preserves backward-compatible identify() returning Promise<void>.
export type { LDClient } from './LDClient';

export * from './hooks';
export * from './provider';
export * from './LDPlugin';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ describe('LDClientImpl.start()', () => {
initialContext: context,
});

const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' });
const result = await ldc.identify({ kind: 'user', key: 'other-user' });

expect(result.status).toBe('error');
if (result.status === 'error') {
Expand All @@ -263,15 +263,15 @@ describe('LDClientImpl.start()', () => {

await ldc.start();

const result = await ldc.identifyResult({ kind: 'user', key: 'other-user' });
const result = await ldc.identify({ kind: 'user', key: 'other-user' });
expect(result.status).toBe('completed');
});

it('allows identify without start when requiresStart is false', async () => {
const mockPlatform = setupStreamingPlatform();
const { ldc } = setupClient(mockPlatform, { requiresStart: false });

const result = await ldc.identifyResult(context);
const result = await ldc.identify(context);
expect(result.status).toBe('completed');
});

Expand All @@ -280,9 +280,9 @@ describe('LDClientImpl.start()', () => {
const { ldc } = setupClient(mockPlatform, { requiresStart: true, initialContext: context });

const startPromise = ldc.start();
const promise1 = ldc.identifyResult({ kind: 'user', key: 'user-1' });
const promise2 = ldc.identifyResult({ kind: 'user', key: 'user-2' });
const promise3 = ldc.identifyResult({ kind: 'user', key: 'user-3' });
const promise1 = ldc.identify({ kind: 'user', key: 'user-1' });
const promise2 = ldc.identify({ kind: 'user', key: 'user-2' });
const promise3 = ldc.identify({ kind: 'user', key: 'user-3' });

const [startResult, result1, result2, result3] = await Promise.all([
startPromise,
Expand Down
14 changes: 8 additions & 6 deletions packages/shared/sdk-client/__tests__/LDClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,17 +285,17 @@ describe('sdk-client object', () => {
const carContext = { kind: 'car' };

// @ts-expect-error - invalid context
await expect(ldc.identify(carContext)).rejects.toThrow(/no key/);
expect(logger.error).toHaveBeenCalledTimes(1);
const result = await ldc.identify(carContext);
expect(result).toEqual(expect.objectContaining({ status: 'error', error: expect.any(Error) }));
expect(ldc.getContext()).toBeUndefined();
});

test('identify error invalid multi kindcontext', async () => {
const carContext = { kind: 'multi', user: { name: 'test' } };

// @ts-ignore - invalid context
await expect(ldc.identify(carContext)).rejects.toThrow(/no key/);
expect(logger.error).toHaveBeenCalledTimes(1);
const result = await ldc.identify(carContext);
expect(result).toEqual(expect.objectContaining({ status: 'error', error: expect.any(Error) }));
expect(ldc.getContext()).toBeUndefined();
});

Expand All @@ -310,7 +310,8 @@ describe('sdk-client object', () => {

const carContext: LDContext = { kind: 'car', key: 'test-car' };

await expect(ldc.identify(carContext)).rejects.toThrow('test-error');
const result = await ldc.identify(carContext);
expect(result).toEqual(expect.objectContaining({ status: 'error' }));
expect(logger.error).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenNthCalledWith(1, expect.stringMatching(/^error:.*test-error/));
expect(logger.error).toHaveBeenNthCalledWith(2, expect.stringContaining('Received error 404'));
Expand Down Expand Up @@ -430,7 +431,8 @@ describe('sdk-client object', () => {
const spyListener = jest.fn();
ldc.on('dataSourceStatus', spyListener);
const changePromise = onDataSourceChangePromise(2);
await expect(ldc.identify(carContext)).rejects.toThrow('test-error');
const identifyResult = await ldc.identify(carContext);
expect(identifyResult).toEqual(expect.objectContaining({ status: 'error' }));
await changePromise;

expect(spyListener).toHaveBeenCalledTimes(2);
Expand Down
29 changes: 17 additions & 12 deletions packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,30 @@ describe('sdk-client identify timeout', () => {
jest.resetAllMocks();
});

test('rejects with default timeout of 5s', async () => {
test('returns timeout result with default timeout of 5s', async () => {
jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT * 1000).then();
await expect(ldc.identify(carContext)).rejects.toThrow(/identify timed out/);
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/identify timed out/));
await expect(ldc.identify(carContext)).resolves.toEqual({
status: 'timeout',
timeout: DEFAULT_IDENTIFY_TIMEOUT,
});
});

test('rejects with custom timeout', async () => {
test('returns timeout result with custom timeout', async () => {
const timeout = 15;
jest.advanceTimersByTimeAsync(timeout * 1000).then();
await expect(ldc.identify(carContext, { timeout })).rejects.toThrow(/identify timed out/);
await expect(ldc.identify(carContext, { timeout })).resolves.toEqual({
status: 'timeout',
timeout,
});
});

test('resolves with default timeout', async () => {
test('resolves with completed status on default timeout', async () => {
// set simulated events to be default response
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT * 1000).then();

await expect(ldc.identify(carContext)).resolves.toBeUndefined();
await expect(ldc.identify(carContext)).resolves.toEqual({ status: 'completed' });

expect(ldc.getContext()).toEqual(expect.objectContaining(toMulti(carContext)));
expect(ldc.allFlags()).toEqual({
Expand All @@ -106,7 +111,7 @@ describe('sdk-client identify timeout', () => {

jest.advanceTimersByTimeAsync(timeout).then();

await expect(ldc.identify(carContext, { timeout })).resolves.toBeUndefined();
await expect(ldc.identify(carContext, { timeout })).resolves.toEqual({ status: 'completed' });

expect(ldc.getContext()).toEqual(expect.objectContaining(toMulti(carContext)));
expect(ldc.allFlags()).toEqual({
Expand Down Expand Up @@ -208,7 +213,7 @@ describe('sdk-client waitForInitialization', () => {
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);
const identifyPromise = ldc.identify(carContext);

jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT).then();

Expand All @@ -222,7 +227,7 @@ describe('sdk-client waitForInitialization', () => {
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);
const identifyPromise = ldc.identify(carContext);

jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT).then();

Expand All @@ -240,7 +245,7 @@ describe('sdk-client waitForInitialization', () => {
simulatedEvents = [];

const waitPromise = ldc.waitForInitialization({ timeout: 10 });
const identifyPromise = ldc.identifyResult(carContext);
const identifyPromise = ldc.identify(carContext);

jest.advanceTimersByTimeAsync(10 * 1000 + 1).then();

Expand Down Expand Up @@ -274,7 +279,7 @@ describe('sdk-client waitForInitialization', () => {
);

const waitPromise = errorLdc.waitForInitialization({ timeout: 10 });
const identifyPromise = errorLdc.identifyResult(carContext);
const identifyPromise = errorLdc.identify(carContext);

// Advance timers to allow error handler to be set up and error to propagate
await jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('sdk-client object', () => {
const p = ldc.identify(context);
const flagValue = ldc.variation('does-not-exist', 'not-found');

await expect(p).resolves.toBeUndefined();
await expect(p).resolves.toEqual({ status: 'completed' });

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Unknown feature'));
expect(flagValue).toBe('not-found');
Expand Down
Loading
Loading