diff --git a/CHANGES.txt b/CHANGES.txt index 8d931493..a5e76102 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,9 @@ +3.0.0 (XXX XX, 2025) + - BREAKING CHANGES: + - Removed the deprecated `client.ready()` method. Use `client.whenReady()` or `client.whenReadyFromCache()` instead. + 2.8.0 (October XX, 2025) - - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which had an issue causing the returned promise to hang when using async/await syntax if it was rejected. + - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which has an issue causing the returned promise to hang when using async/await syntax if it was rejected. - Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted. 2.7.1 (October 8, 2025) diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index 9044fc72..5c2ce99f 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -1,11 +1,12 @@ // @ts-nocheck import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; import SplitIO from '../../../types/splitio'; -import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE } from '../constants'; +import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../constants'; import { sdkReadinessManagerFactory } from '../sdkReadinessManager'; import { IReadinessManager } from '../types'; import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO_LISTENER } from '../../logger/constants'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../utils/MinEvents'; const EventEmitterMock = jest.fn(() => ({ on: jest.fn(), @@ -24,6 +25,7 @@ function emitReadyEvent(readinessManager: IReadinessManager) { readinessManager.segments.once.mock.calls[0][1](); readinessManager.segments.on.mock.calls[0][1](); readinessManager.gate.once.mock.calls[0][1](); + if (readinessManager.gate.once.mock.calls[3]) readinessManager.gate.once.mock.calls[3][1](); // ready promise } const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.'; @@ -32,6 +34,7 @@ const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.'; function emitTimeoutEvent(readinessManager: IReadinessManager) { readinessManager.gate.once.mock.calls[1][1](timeoutErrorMessage); readinessManager.hasTimedout = () => true; + if (readinessManager.gate.once.mock.calls[4]) readinessManager.gate.once.mock.calls[4][1](timeoutErrorMessage); // ready promise } describe('SDK Readiness Manager - Event emitter', () => { @@ -50,7 +53,7 @@ describe('SDK Readiness Manager - Event emitter', () => { expect(sdkStatus[propName]).toBeTruthy(); // The sdkStatus exposes all minimal EventEmitter functionality. }); - expect(typeof sdkStatus.ready).toBe('function'); // The sdkStatus exposes a .ready() function. + expect(typeof sdkStatus.whenReady).toBe('function'); // The sdkStatus exposes a .whenReady() function. expect(typeof sdkStatus.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. expect(sdkStatus.__getStatus()).toEqual({ isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 @@ -199,12 +202,12 @@ describe('SDK Readiness Manager - Event emitter', () => { }); }); -describe('SDK Readiness Manager - Ready promise', () => { +describe('SDK Readiness Manager - whenReady promise', () => { - test('.ready() promise behavior for clients', async () => { + test('.whenReady() promise behavior for clients', async () => { const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - const ready = sdkReadinessManager.sdkStatus.ready(); + const ready = sdkReadinessManager.sdkStatus.whenReady(); expect(ready instanceof Promise).toBe(true); // It should return a promise. // make the SDK "ready" @@ -219,8 +222,8 @@ describe('SDK Readiness Manager - Ready promise', () => { () => { throw new Error('It should be resolved on ready event, not rejected.'); } ); - // any subsequent call to .ready() must be a resolved promise - await ready.then( + // any subsequent call to .whenReady() must be a resolved promise + await sdkReadinessManager.sdkStatus.whenReady().then( () => { expect('A subsequent call should be a resolved promise.'); testPassedCount++; @@ -233,7 +236,7 @@ describe('SDK Readiness Manager - Ready promise', () => { const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.ready(); + const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReady(); emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK "timed out" @@ -245,8 +248,8 @@ describe('SDK Readiness Manager - Ready promise', () => { } ); - // any subsequent call to .ready() must be a rejected promise - await readyForTimeout.then( + // any subsequent call to .whenReady() must be a rejected promise until the SDK is ready + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); }, () => { expect('A subsequent call should be a rejected promise.'); @@ -257,8 +260,8 @@ describe('SDK Readiness Manager - Ready promise', () => { // make the SDK "ready" emitReadyEvent(sdkReadinessManagerForTimedout.readinessManager); - // once SDK_READY, `.ready()` returns a resolved promise - await ready.then( + // once SDK_READY, `.whenReady()` returns a resolved promise + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( () => { expect('It should be a resolved promise when the SDK is ready, even after an SDK timeout.'); loggerMock.mockClear(); @@ -270,57 +273,27 @@ describe('SDK Readiness Manager - Ready promise', () => { }); test('Full blown ready promise count as a callback and resolves on SDK_READY', (done) => { - const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - const readyPromise = sdkReadinessManager.sdkStatus.ready(); + let sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); - // Get the callback - const readyEventCB = sdkReadinessManager.readinessManager.gate.once.mock.calls[0][1]; + // Emit ready event + sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); - readyEventCB(); - expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // We would get the warning if the SDK get\'s ready before attaching any callbacks to ready promise. + expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // We should get a warning if the SDK get's ready before calling the ready method or attaching a listener to the ready event loggerMock.warn.mockClear(); - readyPromise.then(() => { + sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); + sdkReadinessManager.sdkStatus.whenReady().then(() => { expect('The ready promise is resolved when the gate emits SDK_READY.'); done(); }, () => { throw new Error('This should not be called as the promise is being resolved.'); }); - readyEventCB(); - expect(loggerMock.warn).not.toBeCalled(); // But if we have a listener there are no warnings. - }); - - test('.ready() rejected promises have a default onRejected handler that just logs the error', (done) => { - const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - let readyForTimeout = sdkReadinessManager.sdkStatus.ready(); - - emitTimeoutEvent(sdkReadinessManager.readinessManager); // make the SDK "timed out" - - readyForTimeout.then( - () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); } - ); + // Emit ready event + sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); - expect(loggerMock.error).not.toBeCalled(); // not called until promise is rejected - - setTimeout(() => { - expect(loggerMock.error.mock.calls).toEqual([[timeoutErrorMessage]]); // If we don\'t handle the rejected promise, an error is logged. - readyForTimeout = sdkReadinessManager.sdkStatus.ready(); - - setTimeout(() => { - expect(loggerMock.error).lastCalledWith('Split SDK has emitted SDK_READY_TIMED_OUT event.'); // If we don\'t handle a new .ready() rejected promise, an error is logged. - readyForTimeout = sdkReadinessManager.sdkStatus.ready(); - - readyForTimeout - .then(() => { throw new Error(); }) - .then(() => { throw new Error(); }) - .catch((error) => { - expect(error instanceof Error).toBe(true); - expect(error.message).toBe('Split SDK has emitted SDK_READY_TIMED_OUT event.'); - expect(loggerMock.error).toBeCalledTimes(2); // If we provide an onRejected handler, even chaining several onFulfilled handlers, the error is not logged. - done(); - }); - }, 0); - }, 0); + expect(loggerMock.warn).not.toBeCalled(); // But if we have a listener or call the ready method, we get no warnings. }); }); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 62f51571..f500a421 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -1,5 +1,4 @@ import { objectAssign } from '../utils/lang/objectAssign'; -import { promiseWrapper } from '../utils/promise/wrapper'; import { readinessManagerFactory } from './readinessManager'; import { ISdkReadinessManager } from './types'; import { ISettings } from '../types'; @@ -42,34 +41,19 @@ export function sdkReadinessManagerFactory( } }); - /** Ready promise */ - const readyPromise = generateReadyPromise(); + readinessManager.gate.once(SDK_READY, () => { + log.info(CLIENT_READY); - readinessManager.gate.once(SDK_READY_FROM_CACHE, () => { - log.info(CLIENT_READY_FROM_CACHE); + if (readyCbCount === internalReadyCbCount) log.warn(CLIENT_NO_LISTENER); }); - // default onRejected handler, that just logs the error, if ready promise doesn't have one. - function defaultOnRejected(err: any) { - log.error(err && err.message); - } - - function generateReadyPromise() { - const promise = promiseWrapper(new Promise((resolve, reject) => { - readinessManager.gate.once(SDK_READY, () => { - log.info(CLIENT_READY); - - if (readyCbCount === internalReadyCbCount && !promise.hasOnFulfilled()) log.warn(CLIENT_NO_LISTENER); - resolve(); - }); - readinessManager.gate.once(SDK_READY_TIMED_OUT, (message: string) => { - reject(new Error(message)); - }); - }), defaultOnRejected); - - return promise; - } + readinessManager.gate.once(SDK_READY_TIMED_OUT, (message: string) => { + log.error(message); + }); + readinessManager.gate.once(SDK_READY_FROM_CACHE, () => { + log.info(CLIENT_READY_FROM_CACHE); + }); return { readinessManager, @@ -94,18 +78,6 @@ export function sdkReadinessManagerFactory( SDK_READY_TIMED_OUT, }, - // @TODO: remove in next major - ready() { - if (readinessManager.hasTimedout()) { - if (!readinessManager.isReady()) { - return promiseWrapper(Promise.reject(TIMEOUT_ERROR), defaultOnRejected); - } else { - return Promise.resolve(); - } - } - return readyPromise; - }, - whenReady() { return new Promise((resolve, reject) => { if (readinessManager.isReady()) { diff --git a/src/utils/promise/__tests__/wrapper.spec.ts b/src/utils/promise/__tests__/wrapper.spec.ts deleted file mode 100644 index ab44f9d2..00000000 --- a/src/utils/promise/__tests__/wrapper.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -// @ts-nocheck -import { promiseWrapper } from '../wrapper'; - -test('Promise utils / promise wrapper', function (done) { - expect.assertions(58); // number of passHandler, passHandlerFinally, passHandlerWithThrow and `hasOnFulfilled` asserts - - const value = 'value'; - const failHandler = (val) => { done.fail(val); }; - const passHandler = (val) => { expect(val).toBe(value); return val; }; - const passHandlerFinally = (val) => { expect(val).toBeUndefined(); }; - const passHandlerWithThrow = (val) => { expect(val).toBe(value); throw val; }; - const createResolvedPromise = () => new Promise((res) => { setTimeout(() => { res(value); }, 100); }); - const createRejectedPromise = () => new Promise((_, rej) => { setTimeout(() => { rej(value); }, 100); }); - - // resolved promises - let wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(false); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.finally(passHandlerFinally); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler, failHandler).finally(passHandlerFinally); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler).catch(failHandler).finally(passHandlerFinally); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler).catch(failHandler).then(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler).then(passHandler).catch(failHandler).finally(passHandlerFinally).then(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise.then(passHandler).then(passHandlerWithThrow).catch(passHandler).then(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - const wrappedPromise2 = promiseWrapper(createResolvedPromise(), failHandler); - wrappedPromise2.then(() => { - wrappedPromise2.then(passHandler); - }); - expect(wrappedPromise2.hasOnFulfilled()).toBe(true); - - Promise.all([ - promiseWrapper(createResolvedPromise(), failHandler), - promiseWrapper(createResolvedPromise(), failHandler)] - ).then((val) => { expect(val).toEqual([value, value]); }); - - // rejected promises - wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(false); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.catch(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(false); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.catch(passHandler).then(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(false); - - // caveat: setting an `onFinally` handler as the first handler, requires an `onRejected` handler if promise is rejected - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.finally(passHandlerFinally).catch(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); - wrappedPromise.then(undefined, passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(false); - - wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); - wrappedPromise.then(failHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.then(failHandler).then(failHandler).catch(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); - wrappedPromise.then(failHandler).then(failHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.then(failHandler, passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.then(failHandler).catch(passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.then(failHandler).then(failHandler, passHandler); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise.then(failHandler).catch(passHandler).then(passHandler).finally(passHandlerFinally); - expect(wrappedPromise.hasOnFulfilled()).toBe(true); - - const wrappedPromise3 = promiseWrapper(createRejectedPromise(), failHandler); - wrappedPromise3.catch(() => { - wrappedPromise3.catch(passHandler); - }); - expect(wrappedPromise3.hasOnFulfilled()).toBe(false); - - Promise.all([ - promiseWrapper(createResolvedPromise(), failHandler), - promiseWrapper(createRejectedPromise(), failHandler)]).catch(passHandler); - - setTimeout(() => { - done(); - }, 1000); - -}); - -test('Promise utils / promise wrapper: async/await', async () => { - - expect.assertions(8); // number of passHandler, passHandlerWithThrow and passHandlerFinally - - const value = 'value'; - const failHandler = (val) => { throw val; }; - const passHandler = (val) => { expect(val).toBe(value); return val; }; - const passHandlerFinally = (val) => { expect(val).toBeUndefined(); }; - const passHandlerWithThrow = (val) => { expect(val).toBe(value); throw val; }; - const createResolvedPromise = () => new Promise((res) => { res(value); }); - const createRejectedPromise = () => new Promise((res, rej) => { rej(value); }); - - try { - const result = await promiseWrapper(createResolvedPromise(), failHandler); - passHandler(result); - } catch (result) { - failHandler(result); - } finally { - passHandlerFinally(); - } - - try { - const result = await promiseWrapper(createRejectedPromise(), failHandler); - failHandler(result); - } catch (result) { - passHandler(result); - } - - let result; - try { - result = await promiseWrapper(createResolvedPromise(), failHandler); - passHandler(result); - passHandlerWithThrow(result); - } catch (error) { - result = passHandler(error); - } finally { - passHandlerFinally(); - } - passHandler(result); -}); diff --git a/src/utils/promise/wrapper.ts b/src/utils/promise/wrapper.ts deleted file mode 100644 index 62266457..00000000 --- a/src/utils/promise/wrapper.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * wraps a given promise in a new one with a default onRejected function, - * that handles the promise rejection if not other onRejected handler is provided. - * - * Caveats: - * - There are some cases where the `defaultOnRejected` handler is not invoked - * and the promise rejection must be handled by the user (same as the Promise spec): - * - using async/await syntax with a transpiler to Promises - * - setting an `onFinally` handler as the first handler (e.g. `promiseWrapper(Promise.reject()).finally(...)`) - * - setting more than one handler with at least one of them being an onRejected handler - * - If the wrapped promise is rejected when using native async/await syntax, the `defaultOnRejected` handler is invoked - * and neither the catch block nor the remaining try block are executed. - * - * @param customPromise - promise to wrap - * @param defaultOnRejected - default onRejected function - * @returns a promise that doesn't need to be handled for rejection (except when using async/await syntax) and - * includes a method named `hasOnFulfilled` that returns true if the promise has attached an onFulfilled handler. - */ -export function promiseWrapper(customPromise: Promise, defaultOnRejected: (_: any) => any): Promise & { hasOnFulfilled: () => boolean } { - - let hasOnFulfilled = false; - let hasOnRejected = false; - - function chain(promise: Promise): Promise { - const newPromise: Promise = new Promise((res, rej) => { - return promise.then( - res, - function (value) { - if (hasOnRejected) { - rej(value); - } else { - defaultOnRejected(value); - } - } - ); - }); - - const originalThen = newPromise.then; - - // Using `defineProperty` in case Promise.prototype.then property is not writable - Object.defineProperty(newPromise, 'then', { - value: function (onfulfilled: any, onrejected: any) { - const result: Promise = originalThen.call(newPromise, onfulfilled, onrejected); - if (typeof onfulfilled === 'function') hasOnFulfilled = true; - if (typeof onrejected === 'function') { - hasOnRejected = true; - return result; - } else { - return chain(result); - } - } - }); - - return newPromise; - } - - const result = chain(customPromise) as Promise & { hasOnFulfilled: () => boolean }; - result.hasOnFulfilled = () => hasOnFulfilled; - return result; -} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 49f70c62..e61ffb22 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -699,25 +699,6 @@ declare namespace SplitIO { * Constant object containing the SDK events for you to use. */ Event: EventConsts; - /** - * Returns a promise that resolves when the SDK has finished initial synchronization with the backend (`SDK_READY` event emitted), or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). - * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. - * - * Caveats: the method was designed to avoid an unhandled Promise rejection if the rejection case is not handled, so that `onRejected` handler is optional when using promises. - * However, when using async/await syntax, the rejection should be explicitly propagated like in the following example: - * ``` - * try { - * await client.ready().catch((e) => { throw e; }); - * // SDK is ready - * } catch(e) { - * // SDK has timedout - * } - * ``` - * - * @returns A promise that resolves once the SDK is ready or rejects if the SDK has timedout. - * @deprecated Use `whenReady` instead. - */ - ready(): Promise; /** * Returns a promise that resolves when the SDK has finished initial synchronization with the backend (`SDK_READY` event emitted), or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility than event listeners, given that the SDK might be ready after a timeout event, the `whenReady` method will return a resolved promise once the SDK is ready.