Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
2.8.0 (October XX, 2025)
- 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)
Expand Down
2 changes: 1 addition & 1 deletion src/logger/messages/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const codesWarn: [number, string][] = codesError.concat([
[c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s, keeping data to retry on next iteration. Reason: %s.'],
// client status
[c.CLIENT_NOT_READY_FROM_CACHE, '%s: the SDK is not ready to evaluate. Results may be incorrect%s. Make sure to wait for SDK readiness before using this method.'],
[c.CLIENT_NO_LISTENER, 'No listeners for SDK Readiness detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet ready.'],
[c.CLIENT_NO_LISTENER, 'No listeners for SDK_READY event detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet synchronized with the backend.'],
// input validation
[c.WARN_SETTING_NULL, '%s: Property "%s" is of invalid type. Setting value to null.'],
[c.WARN_TRIMMING_PROPERTIES, '%s: more than 300 properties were provided. Some of them will be trimmed when processed.'],
Expand Down
156 changes: 96 additions & 60 deletions src/readiness/__tests__/sdkReadinessManager.spec.ts
Original file line number Diff line number Diff line change
@@ -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, SDK_SPLITS_CACHE_LOADED } 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(),
Expand All @@ -19,24 +20,37 @@ const EventEmitterMock = jest.fn(() => ({

// Makes readinessManager emit SDK_READY & update isReady flag
function emitReadyEvent(readinessManager: IReadinessManager) {
if (readinessManager.gate instanceof EventEmitter) {
readinessManager.splits.emit(SDK_SPLITS_ARRIVED);
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
return;
}

readinessManager.splits.once.mock.calls[0][1]();
readinessManager.splits.on.mock.calls[0][1]();
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](); // whenReady promise
}

const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.';

// Makes readinessManager emit SDK_READY_TIMED_OUT & update hasTimedout flag
function emitTimeoutEvent(readinessManager: IReadinessManager) {
if (readinessManager.gate instanceof EventEmitter) {
readinessManager.timeout();
return;
}

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); // whenReady promise
}

describe('SDK Readiness Manager - Event emitter', () => {

afterEach(() => { loggerMock.mockClear(); });
beforeEach(() => { loggerMock.mockClear(); });

test('Providing the gate object to get the SDK status interface that manages events', () => {
expect(typeof sdkReadinessManagerFactory).toBe('function'); // The module exposes a function.
Expand All @@ -50,7 +64,8 @@ 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.whenReadyFromCache).toBe('function'); // The sdkStatus exposes a .whenReadyFromCache() 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
Expand All @@ -67,9 +82,9 @@ describe('SDK Readiness Manager - Event emitter', () => {
const sdkReadyResolvePromiseCall = gateMock.once.mock.calls[0];
const sdkReadyRejectPromiseCall = gateMock.once.mock.calls[1];
const sdkReadyFromCacheListenersCheckCall = gateMock.once.mock.calls[2];
expect(sdkReadyResolvePromiseCall[0]).toBe(SDK_READY); // A one time only subscription is on the SDK_READY event, for resolving the full blown ready promise and to check for callbacks warning.
expect(sdkReadyRejectPromiseCall[0]).toBe(SDK_READY_TIMED_OUT); // A one time only subscription is also on the SDK_READY_TIMED_OUT event, for rejecting the full blown ready promise.
expect(sdkReadyFromCacheListenersCheckCall[0]).toBe(SDK_READY_FROM_CACHE); // A one time only subscription is on the SDK_READY_FROM_CACHE event, to log the event and update internal state.
expect(sdkReadyResolvePromiseCall[0]).toBe(SDK_READY); // A one time only subscription is on the SDK_READY event
expect(sdkReadyRejectPromiseCall[0]).toBe(SDK_READY_TIMED_OUT); // A one time only subscription is also on the SDK_READY_TIMED_OUT event
expect(sdkReadyFromCacheListenersCheckCall[0]).toBe(SDK_READY_FROM_CACHE); // A one time only subscription is on the SDK_READY_FROM_CACHE event

expect(gateMock.on).toBeCalledTimes(2); // It should also add two persistent listeners

Expand Down Expand Up @@ -98,7 +113,7 @@ describe('SDK Readiness Manager - Event emitter', () => {

emitReadyEvent(sdkReadinessManager.readinessManager);

expect(loggerMock.warn).toBeCalledTimes(1); // If the SDK_READY event fires and we have no callbacks for it (neither event nor ready promise) we get a warning.
expect(loggerMock.warn).toBeCalledTimes(1); // If the SDK_READY event fires and we have no callbacks for it (neither event nor whenReady promise) we get a warning.
expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // Telling us there were no listeners and evaluations before this point may have been incorrect.

expect(loggerMock.info).toBeCalledTimes(1); // If the SDK_READY event fires, we get a info message.
Expand Down Expand Up @@ -199,77 +214,98 @@ describe('SDK Readiness Manager - Event emitter', () => {
});
});

describe('SDK Readiness Manager - Ready promise', () => {
describe('SDK Readiness Manager - Promises', () => {

test('.ready() promise behavior for clients', async () => {
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings);
test('.whenReady() and .whenReadyFromCache() promises resolves when SDK_READY is emitted', async () => {
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);

// make the SDK ready from cache
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false);

const ready = sdkReadinessManager.sdkStatus.ready();
expect(ready instanceof Promise).toBe(true); // It should return a promise.
// validate error log for SDK_READY_FROM_CACHE
expect(loggerMock.error).not.toBeCalled();
sdkReadinessManager.readinessManager.gate.on(SDK_READY_FROM_CACHE, () => {});
expect(loggerMock.error).toBeCalledWith(ERROR_CLIENT_LISTENER, ['SDK_READY_FROM_CACHE']);

// make the SDK "ready"
const readyFromCache = sdkReadinessManager.sdkStatus.whenReadyFromCache();
const ready = sdkReadinessManager.sdkStatus.whenReady();

// make the SDK ready
emitReadyEvent(sdkReadinessManager.readinessManager);
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(true);

let testPassedCount = 0;
await ready.then(
() => {
expect('It should be a promise that will be resolved when the SDK is ready.');
testPassedCount++;
},
() => { throw new Error('It should be resolved on ready event, not rejected.'); }
);
function incTestPassedCount() { testPassedCount++; }
function throwTestFailed() { throw new Error('It should be resolved, not rejected.'); }

// any subsequent call to .ready() must be a resolved promise
await ready.then(
() => {
expect('A subsequent call should be a resolved promise.');
testPassedCount++;
},
() => { throw new Error('It should be resolved on ready event, not rejected.'); }
);
await readyFromCache.then(incTestPassedCount, throwTestFailed);
await ready.then(incTestPassedCount, throwTestFailed);

// control assertion. stubs already reset.
expect(testPassedCount).toBe(2);
// any subsequent call to .whenReady() and .whenReadyFromCache() must be a resolved promise
await sdkReadinessManager.sdkStatus.whenReady().then(incTestPassedCount, throwTestFailed);
await sdkReadinessManager.sdkStatus.whenReadyFromCache().then(incTestPassedCount, throwTestFailed);

const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitterMock, fullSettings);
expect(testPassedCount).toBe(4);
});

const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.ready();
test('.whenReady() and .whenReadyFromCache() promises reject when SDK_READY_TIMED_OUT is emitted before SDK_READY', async () => {
const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitter, fullSettings);

emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK "timed out"
const readyFromCacheForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache();
const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReady();

await readyForTimeout.then(
() => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); },
() => {
expect('It should be a promise that will be rejected when the SDK is timed out.');
testPassedCount++;
}
);
emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK timeout

// any subsequent call to .ready() must be a rejected promise
await readyForTimeout.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.');
testPassedCount++;
}
);
let testPassedCount = 0;
function incTestPassedCount() { testPassedCount++; }
function throwTestFailed() { throw new Error('It should rejected, not resolved.'); }

await readyFromCacheForTimeout.then(throwTestFailed,incTestPassedCount);
await readyForTimeout.then(throwTestFailed,incTestPassedCount);

// make the SDK "ready"
// any subsequent call to .whenReady() and .whenReadyFromCache() must be a rejected promise until the SDK is ready
await sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache().then(throwTestFailed,incTestPassedCount);
await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then(throwTestFailed,incTestPassedCount);

// make the SDK ready
emitReadyEvent(sdkReadinessManagerForTimedout.readinessManager);

// once SDK_READY, `.ready()` returns a resolved promise
await ready.then(
() => {
expect('It should be a resolved promise when the SDK is ready, even after an SDK timeout.');
loggerMock.mockClear();
testPassedCount++;
expect(testPassedCount).toBe(5);
},
() => { throw new Error('It should be resolved on ready event, not rejected.'); }
);
// once SDK_READY, `.whenReady()` returns a resolved promise
await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then(incTestPassedCount, throwTestFailed);
await sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache().then(incTestPassedCount, throwTestFailed);

expect(testPassedCount).toBe(6);
});

test('whenReady promise counts as an SDK_READY listener', (done) => {
let sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);

emitReadyEvent(sdkReadinessManager.readinessManager);

expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // We should get a warning if the SDK get's ready before calling the whenReady method or attaching a listener to the ready event
loggerMock.warn.mockClear();

sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);
sdkReadinessManager.sdkStatus.whenReady().then(() => {
expect('whenReady promise is resolved when the gate emits SDK_READY.');
done();
}, () => {
throw new Error('This should not be called as the promise is being resolved.');
});

emitReadyEvent(sdkReadinessManager.readinessManager);

expect(loggerMock.warn).not.toBeCalled(); // But if we have a listener or call the whenReady method, we get no warnings.
});
});

// @TODO: remove in next major
describe('SDK Readiness Manager - Ready promise', () => {

beforeEach(() => { loggerMock.mockClear(); });

test('Full blown ready promise count as a callback and resolves on SDK_READY', (done) => {
test('ready promise count as a callback and resolves on SDK_READY', (done) => {
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings);
const readyPromise = sdkReadinessManager.sdkStatus.ready();

Expand Down
4 changes: 2 additions & 2 deletions src/readiness/readinessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function readinessManagerFactory(
if (!isReady && !isDestroyed) {
try {
syncLastUpdate();
gate.emit(SDK_READY_FROM_CACHE);
gate.emit(SDK_READY_FROM_CACHE, isReady);
} catch (e) {
// throws user callback exceptions in next tick
setTimeout(() => { throw e; }, 0);
Expand All @@ -116,7 +116,7 @@ export function readinessManagerFactory(
syncLastUpdate();
if (!isReadyFromCache) {
isReadyFromCache = true;
gate.emit(SDK_READY_FROM_CACHE);
gate.emit(SDK_READY_FROM_CACHE, isReady);
}
gate.emit(SDK_READY);
} catch (e) {
Expand Down
30 changes: 30 additions & 0 deletions src/readiness/sdkReadinessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO

const NEW_LISTENER_EVENT = 'newListener';
const REMOVE_LISTENER_EVENT = 'removeListener';
const TIMEOUT_ERROR = new Error(SDK_READY_TIMED_OUT);

/**
* SdkReadinessManager factory, which provides the public status API of SDK clients and manager: ready promise, readiness event emitter and constants (SDK_READY, etc).
Expand Down Expand Up @@ -38,6 +39,8 @@ export function sdkReadinessManagerFactory(
} else if (event === SDK_READY) {
readyCbCount++;
}
} else if (event === SDK_READY_FROM_CACHE && readinessManager.isReadyFromCache()) {
log.error(ERROR_CLIENT_LISTENER, ['SDK_READY_FROM_CACHE']);
}
});

Expand Down Expand Up @@ -93,6 +96,7 @@ export function sdkReadinessManagerFactory(
SDK_READY_TIMED_OUT,
},

// @TODO: remove in next major
ready() {
if (readinessManager.hasTimedout()) {
if (!readinessManager.isReady()) {
Expand All @@ -104,6 +108,32 @@ export function sdkReadinessManagerFactory(
return readyPromise;
},

whenReady() {
return new Promise<void>((resolve, reject) => {
if (readinessManager.isReady()) {
resolve();
} else if (readinessManager.hasTimedout()) {
reject(TIMEOUT_ERROR);
} else {
readinessManager.gate.once(SDK_READY, resolve);
readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR));
}
});
},

whenReadyFromCache() {
return new Promise<boolean>((resolve, reject) => {
if (readinessManager.isReadyFromCache()) {
resolve(readinessManager.isReady());
} else if (readinessManager.hasTimedout()) {
reject(TIMEOUT_ERROR);
} else {
readinessManager.gate.once(SDK_READY_FROM_CACHE, () => resolve(readinessManager.isReady()));
readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR));
}
});
},

__getStatus() {
return {
isReady: readinessManager.isReady(),
Expand Down
Loading