From 96a6b1cb58a39f50253937425eb2f95e362b14b5 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 14 Jan 2026 11:14:34 -0600 Subject: [PATCH 1/2] refactor: remove compat module --- .../compat/LDClientCompatImpl.test.ts | 531 ------------------ packages/sdk/browser/package.json | 10 - .../sdk/browser/src/compat/LDClientCompat.ts | 151 ----- .../browser/src/compat/LDClientCompatImpl.ts | 270 --------- .../sdk/browser/src/compat/LDCompatOptions.ts | 19 - .../sdk/browser/src/compat/LDEmitterCompat.ts | 79 --- packages/sdk/browser/src/compat/index.ts | 58 -- .../browser/src/compat/wrapPromiseCallback.ts | 40 -- packages/sdk/browser/tsup.config.ts | 1 - packages/sdk/browser/upgrade/README.md | 8 - packages/sdk/browser/upgrade/v4.md | 240 -------- .../src/hooks/variation/LDEvaluationDetail.ts | 18 + .../src/hooks/variation/useTypedVariation.ts | 4 +- packages/sdk/react-native/src/index.ts | 4 + .../sdk-client/src/api/LDEvaluationDetail.ts | 6 +- 15 files changed, 26 insertions(+), 1413 deletions(-) delete mode 100644 packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts delete mode 100644 packages/sdk/browser/src/compat/LDClientCompat.ts delete mode 100644 packages/sdk/browser/src/compat/LDClientCompatImpl.ts delete mode 100644 packages/sdk/browser/src/compat/LDCompatOptions.ts delete mode 100644 packages/sdk/browser/src/compat/LDEmitterCompat.ts delete mode 100644 packages/sdk/browser/src/compat/index.ts delete mode 100644 packages/sdk/browser/src/compat/wrapPromiseCallback.ts delete mode 100644 packages/sdk/browser/upgrade/README.md delete mode 100644 packages/sdk/browser/upgrade/v4.md create mode 100644 packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts diff --git a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts b/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts deleted file mode 100644 index c38aa20fca..0000000000 --- a/packages/sdk/browser/__tests__/compat/LDClientCompatImpl.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { jest } from '@jest/globals'; - -import { LDContext, LDFlagSet } from '@launchdarkly/js-client-sdk-common'; - -// Import after mocking -import { makeClient } from '../../src/BrowserClient'; -import LDClientCompatImpl from '../../src/compat/LDClientCompatImpl'; -import { LDOptions } from '../../src/compat/LDCompatOptions'; -import { LDClient } from '../../src/LDClient'; - -// @ts-ignore -const mockBrowserClient: jest.MockedObject = { - identify: jest.fn(), - allFlags: jest.fn(), - close: jest.fn(), - flush: jest.fn(), - setStreaming: jest.fn(), - on: jest.fn(), - off: jest.fn(), - variation: jest.fn(), - variationDetail: jest.fn(), - boolVariation: jest.fn(), - boolVariationDetail: jest.fn(), - numberVariation: jest.fn(), - numberVariationDetail: jest.fn(), - stringVariation: jest.fn(), - stringVariationDetail: jest.fn(), - jsonVariation: jest.fn(), - jsonVariationDetail: jest.fn(), - track: jest.fn(), - addHook: jest.fn(), - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - getContext: jest.fn(), - start: jest.fn(), -}; - -jest.mock('../../src/BrowserClient', () => ({ - __esModule: true, - makeClient: jest.fn(), -})); - -const mockMakeClient = makeClient as jest.MockedFunction; - -afterEach(() => { - jest.clearAllMocks(); -}); - -beforeEach(() => { - // Restore the mock implementation after clearing - mockMakeClient.mockReturnValue(mockBrowserClient); -}); - -describe('given a LDClientCompatImpl client with mocked browser client that is immediately ready', () => { - let client: LDClientCompatImpl; - - beforeEach(() => { - jest.useFakeTimers(); - mockBrowserClient.identify.mockImplementation(() => Promise.resolve({ status: 'completed' })); - client = new LDClientCompatImpl('env-key', { kind: 'user', key: 'user-key' }); - }); - - it('should resolve waitForInitialization when the client is already initialized', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.identify.mockResolvedValue({ status: 'completed' }); - - await expect(client.waitForInitialization()).resolves.toBeUndefined(); - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); -}); - -describe('given a LDClientCompatImpl client with mocked browser client that initializes after a delay', () => { - let client: LDClientCompatImpl; - - beforeEach(() => { - jest.useFakeTimers(); - mockBrowserClient.identify.mockImplementation( - () => - new Promise((r) => { - setTimeout(() => r({ status: 'completed' }), 100); - }), - ); - client = new LDClientCompatImpl('env-key', { kind: 'user', key: 'user-key' }); - }); - - it('should return a promise from identify when no callback is provided', async () => { - jest.advanceTimersToNextTimer(); - const context: LDContext = { kind: 'user', key: 'new-user' }; - const mockFlags: LDFlagSet = { flag1: true, flag2: false }; - - mockBrowserClient.identify.mockResolvedValue({ status: 'completed' }); - mockBrowserClient.allFlags.mockReturnValue(mockFlags); - - const result = await client.identify(context); - - expect(mockBrowserClient.identify).toHaveBeenCalledWith(context, { - hash: undefined, - sheddable: false, - }); - expect(result).toEqual(mockFlags); - }); - - it('should call the callback when provided to identify', (done) => { - jest.advanceTimersToNextTimer(); - const context: LDContext = { kind: 'user', key: 'new-user' }; - const mockFlags: LDFlagSet = { flag1: true, flag2: false }; - - mockBrowserClient.allFlags.mockReturnValue(mockFlags); - mockBrowserClient.identify.mockImplementation(() => Promise.resolve({ status: 'completed' })); - // Starting advancing the timers for the nest call. The wrapped promises - // do not resolve sychronously. - jest.advanceTimersToNextTimerAsync(); - - client.identify(context, undefined, (err, flags) => { - expect(err).toBeNull(); - expect(flags).toEqual(mockFlags); - done(); - }); - }); - - it('should return a promise from close when no callback is provided', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.close.mockResolvedValue(); - - await expect(client.close()).resolves.toBeUndefined(); - expect(mockBrowserClient.close).toHaveBeenCalled(); - }); - - it('should call the callback when provided to close', (done) => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.close.mockResolvedValue(); - - client.close(() => { - expect(mockBrowserClient.close).toHaveBeenCalled(); - done(); - }); - }); - - it('should return a promise from flush when no callback is provided', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.flush.mockResolvedValue({ result: true }); - - await expect(client.flush()).resolves.toBeUndefined(); - expect(mockBrowserClient.flush).toHaveBeenCalled(); - }); - - it('should call the callback when provided to flush', (done) => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.flush.mockResolvedValue({ result: true }); - - // Starting advancing the timers for the nest call. The wrapped promises - // do not resolve sychronously. - jest.advanceTimersToNextTimerAsync(); - jest.advanceTimersToNextTimerAsync(); - client.flush(() => { - expect(mockBrowserClient.flush).toHaveBeenCalled(); - done(); - }); - }); - - it('should resolve waitForInitialization when the client is initialized', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.identify.mockResolvedValue({ status: 'completed' }); - - await expect(client.waitForInitialization()).resolves.toBeUndefined(); - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should resolve second waitForInitialization immediately', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.identify.mockResolvedValue({ status: 'completed' }); - - await expect(client.waitForInitialization()).resolves.toBeUndefined(); - await expect(client.waitForInitialization()).resolves.toBeUndefined(); - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should resolve waitUntilReady immediately if the client is already initialized', async () => { - jest.advanceTimersToNextTimer(); - mockBrowserClient.identify.mockResolvedValue({ status: 'completed' }); - - await expect(client.waitUntilReady()).resolves.toBeUndefined(); - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should log a warning when no timeout is specified for waitForInitialization', async () => { - jest.advanceTimersToNextTimerAsync(); - await client.waitForInitialization(); - - expect(mockBrowserClient.logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'The waitForInitialization function was called without a timeout specified.', - ), - ); - }); - - it('should apply a timeout when specified for waitForInitialization', async () => { - jest.useRealTimers(); - await expect(async () => client.waitForInitialization(0.25)).rejects.toThrow(); - }); - - it('should pass through allFlags call', () => { - const mockFlags = { flag1: true, flag2: false }; - mockBrowserClient.allFlags.mockReturnValue(mockFlags); - - const result = client.allFlags(); - - expect(result).toEqual(mockFlags); - expect(mockBrowserClient.allFlags).toHaveBeenCalled(); - }); - - it('should pass through variation call', () => { - const flagKey = 'test-flag'; - const defaultValue = false; - mockBrowserClient.variation.mockReturnValue(true); - - const result = client.variation(flagKey, defaultValue); - - expect(result).toBe(true); - expect(mockBrowserClient.variation).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through variationDetail call', () => { - const flagKey = 'test-flag'; - const defaultValue = 'default'; - const mockDetail = { value: 'test', variationIndex: 1, reason: { kind: 'OFF' } }; - mockBrowserClient.variationDetail.mockReturnValue(mockDetail); - - const result = client.variationDetail(flagKey, defaultValue); - - expect(result).toEqual(mockDetail); - expect(mockBrowserClient.variationDetail).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through boolVariation call', () => { - const flagKey = 'bool-flag'; - const defaultValue = false; - mockBrowserClient.boolVariation.mockReturnValue(true); - - const result = client.boolVariation(flagKey, defaultValue); - - expect(result).toBe(true); - expect(mockBrowserClient.boolVariation).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through boolVariationDetail call', () => { - const flagKey = 'bool-flag'; - const defaultValue = false; - const mockDetail = { value: true, variationIndex: 1, reason: { kind: 'OFF' } }; - mockBrowserClient.boolVariationDetail.mockReturnValue(mockDetail); - - const result = client.boolVariationDetail(flagKey, defaultValue); - - expect(result).toEqual(mockDetail); - expect(mockBrowserClient.boolVariationDetail).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through stringVariation call', () => { - const flagKey = 'string-flag'; - const defaultValue = 'default'; - mockBrowserClient.stringVariation.mockReturnValue('test'); - - const result = client.stringVariation(flagKey, defaultValue); - - expect(result).toBe('test'); - expect(mockBrowserClient.stringVariation).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through stringVariationDetail call', () => { - const flagKey = 'string-flag'; - const defaultValue = 'default'; - const mockDetail = { value: 'test', variationIndex: 1, reason: { kind: 'OFF' } }; - mockBrowserClient.stringVariationDetail.mockReturnValue(mockDetail); - - const result = client.stringVariationDetail(flagKey, defaultValue); - - expect(result).toEqual(mockDetail); - expect(mockBrowserClient.stringVariationDetail).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through numberVariation call', () => { - const flagKey = 'number-flag'; - const defaultValue = 0; - mockBrowserClient.numberVariation.mockReturnValue(42); - - const result = client.numberVariation(flagKey, defaultValue); - - expect(result).toBe(42); - expect(mockBrowserClient.numberVariation).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through numberVariationDetail call', () => { - const flagKey = 'number-flag'; - const defaultValue = 0; - const mockDetail = { value: 42, variationIndex: 1, reason: { kind: 'OFF' } }; - mockBrowserClient.numberVariationDetail.mockReturnValue(mockDetail); - - const result = client.numberVariationDetail(flagKey, defaultValue); - - expect(result).toEqual(mockDetail); - expect(mockBrowserClient.numberVariationDetail).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through jsonVariation call', () => { - const flagKey = 'json-flag'; - const defaultValue = { default: true }; - const mockJson = { test: 'value' }; - mockBrowserClient.jsonVariation.mockReturnValue(mockJson); - - const result = client.jsonVariation(flagKey, defaultValue); - - expect(result).toEqual(mockJson); - expect(mockBrowserClient.jsonVariation).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through jsonVariationDetail call', () => { - const flagKey = 'json-flag'; - const defaultValue = { default: true }; - const mockDetail = { value: { test: 'value' }, variationIndex: 1, reason: { kind: 'OFF' } }; - mockBrowserClient.jsonVariationDetail.mockReturnValue(mockDetail); - - const result = client.jsonVariationDetail(flagKey, defaultValue); - - expect(result).toEqual(mockDetail); - expect(mockBrowserClient.jsonVariationDetail).toHaveBeenCalledWith(flagKey, defaultValue); - }); - - it('should pass through track call', () => { - const eventName = 'test-event'; - const data = { key: 'value' }; - const metricValue = 1.5; - - client.track(eventName, data, metricValue); - - expect(mockBrowserClient.track).toHaveBeenCalledWith(eventName, data, metricValue); - }); - - it('should pass through getContext call', () => { - const mockContext = { kind: 'user', key: 'user-key' }; - mockBrowserClient.getContext.mockReturnValue(mockContext); - - const result = client.getContext(); - - expect(result).toEqual(mockContext); - expect(mockBrowserClient.getContext).toHaveBeenCalled(); - }); - - it('should pass through setStreaming call', () => { - const streamingEnabled = true; - - client.setStreaming(streamingEnabled); - - expect(mockBrowserClient.setStreaming).toHaveBeenCalledWith(streamingEnabled); - }); - - it('should emit ready and initialized events', async () => { - const readyListener = jest.fn(); - const initializedListener = jest.fn(); - - client.on('ready', readyListener); - client.on('initialized', initializedListener); - - jest.advanceTimersToNextTimerAsync(); - await client.waitForInitialization(); - - expect(readyListener).toHaveBeenCalledTimes(1); - expect(initializedListener).toHaveBeenCalledTimes(1); - }); - - it('it unregisters ready andinitialized handlers handlers', async () => { - const readyListener = jest.fn(); - const initializedListener = jest.fn(); - - client.on('ready', readyListener); - client.on('initialized', initializedListener); - - client.off('ready', readyListener); - client.off('initialized', initializedListener); - - jest.advanceTimersToNextTimerAsync(); - await client.waitForInitialization(); - - expect(readyListener).not.toHaveBeenCalled(); - expect(initializedListener).not.toHaveBeenCalled(); - }); - - it('forwards addHook calls to BrowserClient', () => { - const testHook = { - getMetadata: () => ({ name: 'Test Hook' }), - }; - - client.addHook(testHook); - - expect(mockBrowserClient.addHook).toHaveBeenCalledWith(testHook); - }); -}); - -it('forwards bootstrap and hash to BrowserClient identify call', async () => { - mockBrowserClient.identify.mockImplementation( - () => - new Promise((r) => { - setTimeout(r, 100); - }), - ); - const bootstrapData = { flagKey: { version: 1, variation: 0, value: true } }; - const options: LDOptions = { - bootstrap: bootstrapData, - hash: 'someHash', - }; - const context: LDContext = { kind: 'user', key: 'user-key' }; - - // We are testing side-effects, ignore we are not assigning the client. - // eslint-disable-next-line no-new - new LDClientCompatImpl('env-key', context, options); - - expect(mockBrowserClient.identify).toHaveBeenCalledWith(context, { - bootstrap: bootstrapData, - hash: 'someHash', - noTimeout: true, - sheddable: false, - }); -}); - -describe('given a LDClientCompatImpl client with mocked browser client which fails to initialize', () => { - let client: LDClientCompatImpl; - - beforeEach(() => { - jest.useFakeTimers(); - mockBrowserClient.identify.mockImplementation( - () => - new Promise((r, reject) => { - setTimeout(() => reject(new Error('Identify failed')), 100); - }), - ); - client = new LDClientCompatImpl('env-key', { kind: 'user', key: 'user-key' }); - }); - - it('should handle rejection of initial identification before waitForInitialization is called', async () => { - await jest.advanceTimersToNextTimer(); - - await expect(client.waitForInitialization()).rejects.toThrow('Identify failed'); - - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should handle rejection of initial identification after waitForInitialization is called', async () => { - const makeAssertion = () => - expect(client.waitForInitialization()).rejects.toThrow('Identify failed'); - const promise = makeAssertion(); - jest.advanceTimersToNextTimer(); - await promise; - - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should handle rejection of initial identification before waitUntilReady is called', async () => { - await jest.advanceTimersToNextTimer(); - - await expect(client.waitUntilReady()).resolves.toBeUndefined(); - - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should handle rejection of initial identification after waitUntilReady is called', async () => { - const makeAssertion = () => expect(client.waitUntilReady()).resolves.toBeUndefined(); - const promise = makeAssertion(); - jest.advanceTimersToNextTimer(); - await promise; - - expect(mockBrowserClient.identify).toHaveBeenCalledWith( - { kind: 'user', key: 'user-key' }, - { bootstrap: undefined, hash: undefined, noTimeout: true, sheddable: false }, - ); - }); - - it('should emit failed and ready events', async () => { - const readyListener = jest.fn(); - const failedListener = jest.fn(); - - client.on('ready', readyListener); - client.on('failed', failedListener); - - jest.advanceTimersToNextTimerAsync(); - await expect(client.waitForInitialization()).rejects.toThrow('Identify failed'); - - expect(readyListener).toHaveBeenCalledTimes(1); - expect(failedListener).toHaveBeenCalledTimes(1); - }); - - it('it unregisters failed handlers', async () => { - const readyListener = jest.fn(); - const failedListener = jest.fn(); - - client.on('ready', readyListener); - client.on('failed', failedListener); - - client.off('ready', readyListener); - client.off('failed', failedListener); - - jest.advanceTimersToNextTimerAsync(); - await expect(client.waitForInitialization()).rejects.toThrow('Identify failed'); - - expect(readyListener).not.toHaveBeenCalled(); - expect(failedListener).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 6c6c0dff99..0225eddddb 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -30,16 +30,6 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" } - }, - "./compat": { - "require": { - "types": "./dist/compat.d.cts", - "require": "./dist/compat.cjs" - }, - "import": { - "types": "./dist/compat.d.ts", - "import": "./dist/compat.js" - } } }, "files": [ diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts deleted file mode 100644 index 48e792cd22..0000000000 --- a/packages/sdk/browser/src/compat/LDClientCompat.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { LDContext, LDFlagSet } from '@launchdarkly/js-client-sdk-common'; - -import { LDClient as LDCLientBrowser } from '../LDClient'; - -/** - * Compatibility interface. This interface extends the base LDCLient interface with functions - * that improve backwards compatibility. - * - * If starting a new project please import the root package instead of `/compat`. - * - * In the `launchdarkly-js-client-sdk@3.x` package a number of functions had the return typings - * incorrect. Any function which optionally returned a promise based on a callback had incorrect - * typings. Those have been corrected in this implementation. - */ -export interface LDClient extends Omit< - LDCLientBrowser, - | 'close' - | 'flush' - | 'identify' - | 'identifyResult' - | 'waitForInitialization' - | 'setInitialContext' - | 'start' -> { - /** - * Identifies a context to LaunchDarkly. - * - * Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state, - * which is set at initialization time. You only need to call `identify()` if the context has changed - * since then. - * - * 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 - * use a callback or a Promise to determine when the new flag values are available. - * - * @param context - * The context properties. Must contain at least the `key` property. - * @param hash - * The signed context key if you are using [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). - * @param onDone - * A function which will be called as soon as the flag values for the new context are available, - * with two parameters: an error value (if any), and an {@link LDFlagSet} containing the new values - * (which can also be obtained by calling {@link variation}). If the callback is omitted, you will - * receive a Promise instead. - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolve once the flag - * values for the new context are available, providing an {@link LDFlagSet} containing the new values - * (which can also be obtained by calling {@link variation}). - */ - identify( - context: LDContext, - hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void, - ): Promise | undefined; - - /** - * 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 has irrevocably failed (for instance, if it detects that the SDK key is invalid). - * - * ``` - * // 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 `"failed"` indicates failure. - * - * @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. - * - * If no timeout is specified, then the returned promise will only be resolved when the client - * successfully initializes or initialization fails. - * - * @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; - - /** - * Returns a Promise that tracks the client's initialization state. - * - * The returned Promise will be resolved once the client has either successfully initialized - * or failed to initialize (e.g. due to an invalid environment key or a server error). It will - * never be rejected. - * - * ``` - * // using async/await - * await client.waitUntilReady(); - * doSomethingWithClient(); - * ``` - * - * If you want to distinguish between these success and failure conditions, use - * {@link waitForInitialization} instead. - * - * If you prefer to use event listeners ({@link on}) rather than Promises, you can listen on the - * client for a `"ready"` event, which will be fired in either case. - * - * @returns - * A Promise that will be resolved once the client is no longer trying to initialize. - * @deprecated Please use {@link waitForInitialization} instead. This method will always - * cause a warning to be logged because it is implemented via waitForInitialization. - */ - waitUntilReady(): Promise; - - /** - * Shuts down the client and releases its resources, after delivering any pending analytics - * events. - * - * @param onDone - * A function which will be called when the operation completes. If omitted, you - * will receive a Promise instead. - * - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolves once - * closing is finished. It will never be rejected. - */ - close(onDone?: () => void): Promise | undefined; - - /** - * Flushes all pending analytics events. - * - * Normally, batches of events are delivered in the background at intervals determined by the - * `flushInterval` property of {@link LDOptions}. Calling `flush()` triggers an immediate delivery. - * - * @param onDone - * A function which will be called when the flush completes. If omitted, you - * will receive a Promise instead. - * - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolves once - * flushing is finished. Note that the Promise will be rejected if the HTTP request - * fails, so be sure to attach a rejection handler to it. - */ - flush(onDone?: () => void): Promise | undefined; -} diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts deleted file mode 100644 index fc2f640441..0000000000 --- a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts +++ /dev/null @@ -1,270 +0,0 @@ -// TODO may or may not need this. -import { - AutoEnvAttributes, - cancelableTimedPromise, - Hook, - LDContext, - LDContextStrict, - LDEvaluationDetail, - LDEvaluationDetailTyped, - LDFlagSet, - LDFlagValue, - LDLogger, - LDTimeoutError, -} from '@launchdarkly/js-client-sdk-common'; - -import { makeClient } from '../BrowserClient'; -import { LDClient as BrowserLDClient } from '../LDClient'; -import { LDClient } from './LDClientCompat'; -import { LDOptions } from './LDCompatOptions'; -import LDEmitterCompat, { CompatEventName } from './LDEmitterCompat'; -import { wrapPromiseCallback } from './wrapPromiseCallback'; - -export default class LDClientCompatImpl implements LDClient { - private _client: BrowserLDClient; - public readonly logger: LDLogger; - - private _initResolve?: () => void; - - private _initReject?: (err: Error) => void; - - private _rejectionReason: Error | undefined; - - private _initializedPromise?: Promise; - - private _initState: 'success' | 'failure' | 'initializing' = 'initializing'; - - private _emitter: LDEmitterCompat; - - constructor(envKey: string, context: LDContext, options?: LDOptions) { - const bootstrap = options?.bootstrap; - const hash = options?.hash; - - const cleanedOptions = { ...options }; - delete cleanedOptions.bootstrap; - delete cleanedOptions.hash; - this._client = makeClient(envKey, context, AutoEnvAttributes.Disabled, options); - this._emitter = new LDEmitterCompat(this._client); - this.logger = this._client.logger; - - // start the client, then immediately kick off an identify operation - // in order to preserve the behavior of the previous SDK. - this._client.start(); - this._initIdentify(context, bootstrap, hash); - } - - private async _initIdentify( - context: LDContext, - bootstrap?: LDFlagSet, - hash?: string, - ): Promise { - try { - const result = await this._client.identify(context, { - noTimeout: true, - bootstrap, - hash, - sheddable: false, - }); - - if (result.status === 'error') { - throw result.error; - } else if (result.status === 'timeout') { - throw new LDTimeoutError('Identify timed out'); - } - // status === 'completed' ('shed' cannot happen with sheddable: false) - - this._initState = 'success'; - this._initResolve?.(); - this._emitter.emit('initialized'); - } catch (err) { - this._rejectionReason = err as Error; - this._initState = 'failure'; - this._initReject?.(err as Error); - this._emitter.emit('failed', err); - } - // Ready will always be emitted in addition to either 'initialized' or 'failed'. - this._emitter.emit('ready'); - } - - allFlags(): LDFlagSet { - return this._client.allFlags(); - } - - boolVariation(key: string, defaultValue: boolean): boolean { - return this._client.boolVariation(key, defaultValue); - } - - boolVariationDetail(key: string, defaultValue: boolean): LDEvaluationDetailTyped { - return this._client.boolVariationDetail(key, defaultValue); - } - - jsonVariation(key: string, defaultValue: unknown): unknown { - return this._client.jsonVariation(key, defaultValue); - } - - jsonVariationDetail(key: string, defaultValue: unknown): LDEvaluationDetailTyped { - return this._client.jsonVariationDetail(key, defaultValue); - } - - numberVariation(key: string, defaultValue: number): number { - return this._client.numberVariation(key, defaultValue); - } - - numberVariationDetail(key: string, defaultValue: number): LDEvaluationDetailTyped { - return this._client.numberVariationDetail(key, defaultValue); - } - - off(key: CompatEventName, callback: (...args: any[]) => void): void { - this._emitter.off(key, callback); - } - - on(key: CompatEventName, callback: (...args: any[]) => void): void { - this._emitter.on(key, callback); - } - - stringVariation(key: string, defaultValue: string): string { - return this._client.stringVariation(key, defaultValue); - } - - stringVariationDetail(key: string, defaultValue: string): LDEvaluationDetailTyped { - return this._client.stringVariationDetail(key, defaultValue); - } - - track(key: string, data?: any, metricValue?: number): void { - this._client.track(key, data, metricValue); - } - - variation(key: string, defaultValue?: LDFlagValue) { - return this._client.variation(key, defaultValue); - } - - variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { - return this._client.variationDetail(key, defaultValue); - } - - addHook(hook: Hook): void { - this._client.addHook(hook); - } - - setStreaming(streaming?: boolean): void { - this._client.setStreaming(streaming); - } - - identify( - context: LDContext, - hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void, - ): Promise | undefined { - return wrapPromiseCallback( - this._client.identify(context, { hash, sheddable: false }).then((result) => { - // Check if identification was successful - if (result.status === 'error') { - throw result.error; - } else if (result.status === 'timeout') { - throw new LDTimeoutError('Identify timed out'); - } - // status === 'completed' ('shed' cannot happen with sheddable: false) - return this.allFlags(); - }), - onDone, - ) as Promise | undefined; - // The typing here is a little funny. The wrapPromiseCallback can technically return - // `Promise`, but in the case where it would resolve to undefined we are not - // actually using the promise, because it means a callback was specified. - } - - close(onDone?: () => void): Promise | undefined { - return wrapPromiseCallback(this._client.close().then(), onDone); - } - - flush(onDone?: () => void): Promise | undefined { - // The .then() is to strip the return value making a void promise. - return wrapPromiseCallback( - this._client.flush().then(() => undefined), - onDone, - ); - } - - getContext(): LDContextStrict | undefined { - return this._client.getContext(); - } - - waitForInitialization(timeout?: number): Promise { - // 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); - } - - switch (this._initState) { - case 'success': - return Promise.resolve(); - case 'failure': - return Promise.reject(this._rejectionReason); - case 'initializing': - // Continue with the existing logic for creating and handling the promise - break; - default: - throw new Error( - 'Unexpected initialization state. This represents an implementation error in the SDK.', - ); - } - - if (timeout === undefined) { - this.logger?.warn( - 'The waitForInitialization function was called without a timeout specified.' + - ' In a future version a default timeout will be applied.', - ); - } - - if (!this._initializedPromise) { - this._initializedPromise = new Promise((resolve, reject) => { - this._initResolve = resolve; - this._initReject = reject; - }); - } - - return this._promiseWithTimeout(this._initializedPromise, timeout); - } - - async waitUntilReady(): Promise { - try { - await this.waitForInitialization(); - } catch { - // We do not care about the error. - } - } - - /** - * Apply a timeout promise to a base promise. This is for use with waitForInitialization. - * Currently it returns a LDClient. In the future it should return a status. - * - * The client isn't always the expected type of the consumer. It returns an LDClient interface - * which is less capable than, for example, the node client interface. - * - * @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, timeout?: number): Promise { - if (timeout) { - 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; - }); - } - return basePromise; - } -} diff --git a/packages/sdk/browser/src/compat/LDCompatOptions.ts b/packages/sdk/browser/src/compat/LDCompatOptions.ts deleted file mode 100644 index 95235f5b03..0000000000 --- a/packages/sdk/browser/src/compat/LDCompatOptions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { LDFlagSet } from '@launchdarkly/js-client-sdk-common'; - -import { BrowserOptions } from '../options'; - -export interface LDOptions extends BrowserOptions { - /** - * The initial set of flags to use until the remote set is retrieved. - * - * For more information, refer to the - * [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript). - */ - bootstrap?: LDFlagSet; - - /** - * The signed canonical context key, for the initial context, if you are using - * [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). - */ - hash?: string; -} diff --git a/packages/sdk/browser/src/compat/LDEmitterCompat.ts b/packages/sdk/browser/src/compat/LDEmitterCompat.ts deleted file mode 100644 index ca70ac31b8..0000000000 --- a/packages/sdk/browser/src/compat/LDEmitterCompat.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { LDEmitterEventName } from '@launchdarkly/js-client-sdk-common'; - -import { LDClient } from '../LDClient'; - -type CompatOnlyEvents = 'ready' | 'failed' | 'initialized'; -export type CompatEventName = LDEmitterEventName | CompatOnlyEvents; - -const COMPAT_EVENTS: string[] = ['ready', 'failed', 'initialized']; - -export default class LDEmitterCompat { - private _listeners: Map = new Map(); - - constructor(private readonly _client: LDClient) {} - - on(name: CompatEventName, listener: Function) { - if (COMPAT_EVENTS.includes(name)) { - if (!this._listeners.has(name)) { - this._listeners.set(name, [listener]); - } else { - this._listeners.get(name)?.push(listener); - } - } else { - this._client.on(name, listener as (...args: any[]) => void); - } - } - - /** - * Unsubscribe one or all events. - * - * @param name - * @param listener Optional. If unspecified, all listeners for the event will be removed. - */ - off(name: CompatEventName, listener?: Function) { - if (COMPAT_EVENTS.includes(name)) { - const existingListeners = this._listeners.get(name); - if (!existingListeners) { - return; - } - - if (listener) { - const updated = existingListeners.filter((fn) => fn !== listener); - if (updated.length === 0) { - this._listeners.delete(name); - } else { - this._listeners.set(name, updated); - } - return; - } - - // listener was not specified, so remove them all for that event - this._listeners.delete(name); - } else { - this._client.off(name, listener as (...args: any[]) => void); - } - } - - private _invokeListener(listener: Function, name: CompatEventName, ...detail: any[]) { - try { - listener(...detail); - } catch (err) { - this._client.logger.error( - `Encountered error invoking handler for "${name}", detail: "${err}"`, - ); - } - } - - emit(name: CompatEventName, ...detail: any[]) { - const listeners = this._listeners.get(name); - listeners?.forEach((listener) => this._invokeListener(listener, name, ...detail)); - } - - eventNames(): string[] { - return [...this._listeners.keys()]; - } - - listenerCount(name: CompatEventName): number { - return this._listeners.get(name)?.length ?? 0; - } -} diff --git a/packages/sdk/browser/src/compat/index.ts b/packages/sdk/browser/src/compat/index.ts deleted file mode 100644 index d5dbb5a9f3..0000000000 --- a/packages/sdk/browser/src/compat/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * This module provides a compatibility layer which emulates the interface used - * in the launchdarkly-js-client 3.x package. - * - * Some code changes may still be required, for example {@link LDOptions} removes - * support for some previously available options. - */ -import { LDContext } from '@launchdarkly/js-client-sdk-common'; - -import { LDClient } from './LDClientCompat'; -import LDClientCompatImpl from './LDClientCompatImpl'; -import { LDOptions } from './LDCompatOptions'; - -export * from '../common'; -export type { LDClient, LDOptions }; - -/** - * Creates an instance of the LaunchDarkly client. This version of initialization is for - * improved backwards compatibility. In general the `createClient` function from the root packge - * should be used instead of the one in the `/compat` module. - * - * The client will begin attempting to connect to LaunchDarkly as soon as it is created. To - * determine when it is ready to use, call {@link LDClient.waitForInitialization}, or register an - * event listener for the `"ready"` event using {@link LDClient.on}. - * - * Example: - * import { initialize } from '@launchdarkly/js-client-sdk/compat'; - * const client = initialize(envKey, context, options); - * - * Note: The `compat` module minimizes compatibility breaks, but not all functionality is directly - * equivalent to the previous version. - * - * LDOptions are where the primary differences are. By default the new SDK implementation will - * generally use localStorage to cache flags. This can be disabled by setting the - * `maxCachedContexts` to 0. - * - * This does allow combinations that were not possible before. For insance an initial context - * could be identified using bootstrap, and a second context without bootstrap, and the second - * context could cache flags in local storage. For more control the primary module can be used - * instead of this `compat` module (for instance bootstrap can be provided per identify call in - * the primary module). - * - * @param envKey - * The environment ID. - * @param context - * The initial context properties. These can be changed later with {@link LDClient.identify}. - * @param options - * Optional configuration settings. - * @return - * The new client instance. - */ -export function initialize( - envKey: string, - context: LDContext, - options?: LDOptions, -): Omit { - return new LDClientCompatImpl(envKey, context, options); -} diff --git a/packages/sdk/browser/src/compat/wrapPromiseCallback.ts b/packages/sdk/browser/src/compat/wrapPromiseCallback.ts deleted file mode 100644 index efcbc13be0..0000000000 --- a/packages/sdk/browser/src/compat/wrapPromiseCallback.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Wrap a promise to invoke an optional callback upon resolution or rejection. - * - * This function assumes the callback follows the Node.js callback type: (err, value) => void - * - * If a callback is provided: - * - if the promise is resolved, invoke the callback with (null, value) - * - if the promise is rejected, invoke the callback with (error, null) - * - * @param {Promise} promise - * @param {Function} callback - * @returns Promise | undefined - */ -export function wrapPromiseCallback( - promise: Promise, - callback?: (err: Error | null, res: T | null) => void, -): Promise | undefined { - const ret = promise.then( - (value) => { - if (callback) { - setTimeout(() => { - callback(null, value); - }, 0); - } - return value; - }, - (error) => { - if (callback) { - setTimeout(() => { - callback(error, null); - }, 0); - } else { - return Promise.reject(error); - } - return undefined; - }, - ); - - return !callback ? ret : undefined; -} diff --git a/packages/sdk/browser/tsup.config.ts b/packages/sdk/browser/tsup.config.ts index e2c535a07a..cd2c2dbb17 100644 --- a/packages/sdk/browser/tsup.config.ts +++ b/packages/sdk/browser/tsup.config.ts @@ -5,7 +5,6 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: { index: 'src/index.ts', - compat: 'src/compat/index.ts', }, minify: true, format: ['esm', 'cjs'], diff --git a/packages/sdk/browser/upgrade/README.md b/packages/sdk/browser/upgrade/README.md deleted file mode 100644 index a63598ac53..0000000000 --- a/packages/sdk/browser/upgrade/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Upgrade docs - -This directory contains the guides on how to -upgrade between major versions of the js client sdk. - -## Contents - -- [Updating to version 4.0.0](v4.md) \ No newline at end of file diff --git a/packages/sdk/browser/upgrade/v4.md b/packages/sdk/browser/upgrade/v4.md deleted file mode 100644 index 1d8415dc0b..0000000000 --- a/packages/sdk/browser/upgrade/v4.md +++ /dev/null @@ -1,240 +0,0 @@ -# Upgrading to `@launchdarkly/js-client-sdk@4` - -## Overview -Starting from js client sdk version 4, we've moved our source to -this monorepo and the node module will be named `@launchdarkly/js-client-sdk` -moving forward. - -As such, the first steps to updating is installing the "new" package and swapping -all references from `launchdarkly-js-client-sdk` to `@launchdarkly/js-client-sdk`. - -Below, you can find the potentially breaking changes that you will need to address to -successfully upgrade. - -## Key changes - -### Client initialization - -**Key differences:** -- Initialization is now split into 2 parts `createClient` and `start` -- We split the original `LDOptions` that was passed into the `initialize()` function into - 2 types `LDStartOptions` and `LDOptions`. [more here](#ldoptions) -- Removed the use of `initialize` method - -**Before (v3.x):** -```javascript -import LDClient from 'launchdarkly-js-client-sdk'; - -const client = LDClient.initialize('client-side-id', context, options); -``` - -**After (v4.x):** -```javascript -import { createClient } from '@launchdarkly/js-client-sdk'; - -// Create client -const client = createClient('client-side-id', context, options); - -// Then start the client -client.start(); -``` - -**Why this change?** -This two-step process ensures that you can register all event listeners and -perform any necessary setup before the client begins connecting to LaunchDarkly. -This eliminates race conditions where events might be missed if listeners were -registered after the client had already started initializing. - -### LDOptions - -We moved a few things around in `LDOptions` to support the changes that are discussed in -other parts of this doc. Please visit the following docs to see how to interact with our SDK: -- [LDOptions](https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/interfaces/LDOptions.html) that is passed into `createClient()` -- [LDStartOptions](https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/interfaces/LDStartOptions.html) that is passed into `start()` -- [LDIdentifyOptions](https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/interfaces/LDIdentifyOptions.html) that is passed into `identify()` - -**Key differences:** -- `bootstrap` option is moved from `LDOptions` to `LDStartOptions` and `Identify`. The reason for this is that bootstrapping data is - now part of the initialization of a client instance. - > NOTE: that `identify` is a key part of the client initialization process required to associate the instance with an initial context. -- The SDK now defaults to use local storage based caching. - > Previously, in order enable local storage caching, you will need to set this as a special value for the `bootstrap` property. - -### Client initialization flow - -The new SDK uses `waitForInitialization()` which returns a result object instead of rejecting promises. -The old `waitUntilReady()` method has been removed. - -**Key differences:** -- `waitForInitialization()` now always resolves (never rejects) and returns a result object with a `status` field -- The `timeout` is now specified as an option object: `{ timeout: 5 }` instead of a direct parameter -- `waitUntilReady()` has been removed - use `waitForInitialization()` instead -- The result object allows you to handle all cases (success, failure, timeout) without try/catch - -**Before (v3.x):** -```javascript -// Option 1: Using waitUntilReady (never rejects) -await client.waitUntilReady(); - -// Option 2: Using waitForInitialization (rejects on failure) -try { - await client.waitForInitialization(5); -} catch (err) { - // Failure - but this could be an unhandled rejection if not caught -} - -// Option 3: Using event listeners -client.on('ready', () => { - // Client is ready (success or failure) -}); -client.on('initialized', () => { - // Client initialized successfully -}); -client.on('failed', (err) => { - // Client failed to initialize -}); -``` - -**After (v4.x):** -```javascript -// Recommended: Using waitForInitialization (always resolves with status) -const result = await client.waitForInitialization({ timeout: 5 }); - -if (result.status === 'complete') { - // Client initialized successfully -} else if (result.status === 'failed') { - // Client failed to initialize - console.error('Initialization failed:', result.error); -} else if (result.status === 'timeout') { - // Initialization timed out - console.error('Initialization timed out'); -} - -// Note: Events still work if you prefer that approach -client.on('ready', () => { - // Client is ready (success or failure) -}); -client.on('initialized', () => { - // Client initialized successfully -}); -client.on('failed', (err) => { - // Client failed to initialize -}); -``` - -### Identify - -The `identify()` method now always returns a result object and never throws errors. -This provides more predictable error handling. - -**Key differences:** -- `identify()` now takes an options object as the second parameter: `LDIdentifyOptions` instead of a direct hash string -- The method always returns a promise that resolves to a result object (never rejects) -- You must check the `status` field to determine success or failure -- The `hash` parameter is now part of the options object -- `IdentifyOptions` now have a `sheddable` property that is `true` by default. Which means the default behavior for - multiple identify calls is different - [more info here](https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/interfaces/LDIdentifyOptions.html#sheddable) -- Callback support has been removed - use async/await or `.then()` instead -> See [options section](#ldoptions) for more information about changes to the options. - -**Before (v3.x):** -```javascript -// Option 1: Using callback -client.identify(newContext, hash, (err, flags) => { - if (err) { - // Handle error - } else { - // Use flags - } -}); - -// Option 2: Using promise (could throw) -try { - const flags = await client.identify(newContext, hash); - // Use flags -} catch (err) { - // Handle error - but this might be an unhandled rejection -} -``` - -**After (v4.x):** -```javascript -// Always returns a result object, never throws -const result = await client.identify(newContext, { hash }); - -if (result.status === 'completed') { - // Identification completed successfully -} else if (result.status === 'error') { - // Identification encountered an error -} else if (result.status === 'timeout') { - // Identification timed out -} else if (result.status === 'shed') { - // Identification was shed (discarded due to multiple rapid calls) - // This can happen when sheddable option is enabled -} -``` - -### Flag change event payload - -The `change` event now receives the context and an array of changed flag keys as parameters. -> NOTE: we will no longer return the flag value object along with this event - -**Key differences:** -- The `change` event listener now receives `(context, changedFlagKeys)` where `changedFlagKeys` is an array of strings -- The event does not include flag values - you must call `variation()` to get the current value -- Similarly `change:` event listener will now only recieve `(context)` - -**Before (v3.x):** -```javascript -// The exact signature may have varied, but typically: -client.on('change', (changedFlags) => { - // changedFlags is a key value pair where the flag key is mapped - // to a diff object. -}); -``` - -**After (v4.x):** -```javascript -// General change event - fires when any flags change -client.on('change', (context, changedFlagKeys) => { - // context: The LDContext for which flags changed - // changedFlagKeys: Array of flag keys that changed - - // Still need to call variation() to get current values - changedFlagKeys.forEach(flagKey => { - const flagValue = client.variation(flagKey, defaultValue); - }); -}); - -// Specific flag change event - fires when a specific flag changes -client.on('change:my-flag', (context) => { - // Only fires when 'my-flag' changes - const flagValue = client.variation('my-flag', false); -}); -``` - -### Error event handling changes - -The new SDK has changed how errors are logged when error event listeners are present. - -**Key difference:** - -**Before (v3.x):** -In the old SDK, the `maybeReportError` function would check if there was an error listener: -- If an error listener was registered: the error event was emitted but **not logged** to the console -- If no error listener was registered: the error was logged to the console - -> This meant that if you wanted to handle errors yourself, you wouldn't get duplicate error logs. - -**After (v4.x):** -In the new SDK, the client always registers a default error listener that logs errors via the logger. This means: -- Errors are **always logged** via the logger, even if you have your own error listeners -- Your error listeners will still receive the error events -- You may see duplicate error logs if you're also logging errors in your error handler - -**Why this change?** -The new error handling process allows for more robust control over what errors are ignored. The specific case -that we want to avoid is not logging/informing end users when an unhandled error happens. - -Our recommendation is to implement `LDLogger` for your client to suppress errors that are handled diff --git a/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts b/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts new file mode 100644 index 0000000000..a94d1494ea --- /dev/null +++ b/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts @@ -0,0 +1,18 @@ +import { LDEvaluationDetailTyped as CommonLDEvaluationDetailTyped, LDEvaluationReason, } from '@launchdarkly/js-client-sdk-common'; + +/** + * An object that combines the result of a feature flag evaluation with information about + * how it was calculated. + * + * This is the result of calling detailed variation methods. + * + * @remarks + * We will be deprecating this type in favor of {@link CommonLDEvaluationDetailTyped} in the + * next major version. + */ +export type LDEvaluationDetailTyped = Omit, 'reason'> & { + /** + * An optional object describing the main factor that influenced the flag evaluation value. + */ + reason: LDEvaluationReason | null; +}; \ No newline at end of file diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index 72fd632fa4..2922bbcdfa 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -1,4 +1,4 @@ -import { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk-common'; +import { LDEvaluationDetailTyped } from './LDEvaluationDetail'; import useLDClient from '../useLDClient'; @@ -77,6 +77,6 @@ export const useTypedVariationDetail = ; default: - return ldClient.variationDetail(key, defaultValue); + return ldClient.variationDetail(key, defaultValue) as LDEvaluationDetailTyped; } }; diff --git a/packages/sdk/react-native/src/index.ts b/packages/sdk/react-native/src/index.ts index 9a68cca24e..2ac877cd67 100644 --- a/packages/sdk/react-native/src/index.ts +++ b/packages/sdk/react-native/src/index.ts @@ -13,4 +13,8 @@ export * from '@launchdarkly/js-client-sdk-common'; export * from './hooks'; export * from './provider'; export * from './LDPlugin'; + +// Override the common type with a client specific one. +// TODO: we will remove this once we major version this SDK. +export type { LDEvaluationDetailTyped } from './hooks/variation/LDEvaluationDetail'; export { ReactNativeLDClient, RNOptions as LDOptions, RNStorage }; diff --git a/packages/shared/sdk-client/src/api/LDEvaluationDetail.ts b/packages/shared/sdk-client/src/api/LDEvaluationDetail.ts index 4665224565..c66347e954 100644 --- a/packages/shared/sdk-client/src/api/LDEvaluationDetail.ts +++ b/packages/shared/sdk-client/src/api/LDEvaluationDetail.ts @@ -8,8 +8,6 @@ import { // used by server SDKs, has a required reason. This file contains a client specific // LDEvaluationDetail which has an optional reason. -// TODO: On major version change "reason" to be optional instead of nullable. - /** * An object that combines the result of a feature flag evaluation with information about * how it was calculated. @@ -20,7 +18,7 @@ export type LDEvaluationDetail = Omit & { /** * An optional object describing the main factor that influenced the flag evaluation value. */ - reason: LDEvaluationReason | null; + reason?: LDEvaluationReason | null; }; /** @@ -33,5 +31,5 @@ export type LDEvaluationDetailTyped = Omit, 'rea /** * An optional object describing the main factor that influenced the flag evaluation value. */ - reason: LDEvaluationReason | null; + reason?: LDEvaluationReason | null; }; From 8c8a043684813a89f114f6136562e2acbe82e0ac Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 16 Jan 2026 11:48:26 -0600 Subject: [PATCH 2/2] fix: typing for evalation details for client side sdks --- .../src/hooks/variation/LDEvaluationDetail.ts | 33 +++++++++++++++---- .../src/hooks/variation/useTypedVariation.ts | 3 +- packages/sdk/react-native/src/index.ts | 6 +++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts b/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts index a94d1494ea..b43c7f8c61 100644 --- a/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts +++ b/packages/sdk/react-native/src/hooks/variation/LDEvaluationDetail.ts @@ -1,18 +1,37 @@ -import { LDEvaluationDetailTyped as CommonLDEvaluationDetailTyped, LDEvaluationReason, } from '@launchdarkly/js-client-sdk-common'; +import { + LDEvaluationDetail as CommonDetail, + LDEvaluationDetailTyped as CommonDetailTyped, + LDEvaluationReason, +} from '@launchdarkly/js-client-sdk-common'; + +// Implementation note: In client-side SDKs the reason is optional. The common type, which is also +// used by server SDKs, has a required reason. This file contains a client specific +// LDEvaluationDetail which has an optional reason. + +// TODO: On major version change "reason" to be optional instead of nullable. /** * An object that combines the result of a feature flag evaluation with information about * how it was calculated. * - * This is the result of calling detailed variation methods. + * This is the result of calling `LDClient.variationDetail`. + */ +export type LDEvaluationDetail = Omit & { + /** + * An optional object describing the main factor that influenced the flag evaluation value. + */ + reason: LDEvaluationReason | null; +}; + +/** + * An object that combines the result of a feature flag evaluation with information about + * how it was calculated. * - * @remarks - * We will be deprecating this type in favor of {@link CommonLDEvaluationDetailTyped} in the - * next major version. + * This is the result of calling detailed variation methods. */ -export type LDEvaluationDetailTyped = Omit, 'reason'> & { +export type LDEvaluationDetailTyped = Omit, 'reason'> & { /** * An optional object describing the main factor that influenced the flag evaluation value. */ reason: LDEvaluationReason | null; -}; \ No newline at end of file +}; diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index 2922bbcdfa..8bf767a1fe 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -1,6 +1,5 @@ -import { LDEvaluationDetailTyped } from './LDEvaluationDetail'; - import useLDClient from '../useLDClient'; +import { LDEvaluationDetailTyped } from './LDEvaluationDetail'; /** * Determines the strongly typed variation of a feature flag. diff --git a/packages/sdk/react-native/src/index.ts b/packages/sdk/react-native/src/index.ts index 2ac877cd67..cfdd7a1261 100644 --- a/packages/sdk/react-native/src/index.ts +++ b/packages/sdk/react-native/src/index.ts @@ -16,5 +16,9 @@ export * from './LDPlugin'; // Override the common type with a client specific one. // TODO: we will remove this once we major version this SDK. -export type { LDEvaluationDetailTyped } from './hooks/variation/LDEvaluationDetail'; +export type { + LDEvaluationDetailTyped, + LDEvaluationDetail, +} from './hooks/variation/LDEvaluationDetail'; + export { ReactNativeLDClient, RNOptions as LDOptions, RNStorage };