Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1eeff81
Add method to retrieve client readiness status synchronously
EmilianoSanchez Oct 1, 2025
bae6f71
Merge branch 'development' into readiness-status
EmilianoSanchez Oct 6, 2025
83f6e93
Merge pull request #434 from splitio/development
EmilianoSanchez Oct 7, 2025
6d68822
Use log level when logger is set
EmilianoSanchez Oct 8, 2025
3b99e8a
Merge pull request #436 from splitio/custom-logger
EmilianoSanchez Oct 8, 2025
9163c48
Fix typos
EmilianoSanchez Oct 14, 2025
1724ce8
Updated SDK_READY_FROM_CACHE event to be emitted alongside the SDK_RE…
EmilianoSanchez Oct 14, 2025
154c23f
Merge branch 'readiness-sdk-ready-from-cache' into readiness-status
EmilianoSanchez Oct 14, 2025
cfdd6e6
Merge remote-tracking branch 'origin/readiness-status' into readiness…
EmilianoSanchez Oct 14, 2025
53cc6db
feat: add whenReady and whenReadyFromCache methods to replace depreca…
EmilianoSanchez Oct 21, 2025
8b6a8d5
refactor: simplify SDK readiness checks
EmilianoSanchez Oct 22, 2025
96071a4
Merge pull request #438 from splitio/readiness-sdk-ready-from-cache
EmilianoSanchez Oct 22, 2025
2eb71a7
Merge branch 'readiness-baseline' into readiness-status
EmilianoSanchez Oct 22, 2025
1299349
Merge branch 'readiness-status' into readiness-fix-ready-promise
EmilianoSanchez Oct 22, 2025
13eaec3
feat: update whenReadyFromCache to return boolean indicating SDK read…
EmilianoSanchez Oct 22, 2025
e2179d7
Polishing
EmilianoSanchez Oct 22, 2025
e49de68
Revert "Add method to retrieve client readiness status synchronously"
EmilianoSanchez Oct 22, 2025
36ca35e
Polishing
EmilianoSanchez Oct 22, 2025
b8719b2
Polishing
EmilianoSanchez Oct 23, 2025
94814df
Update logs and tests
EmilianoSanchez Oct 23, 2025
d458b54
Merge pull request #445 from splitio/readiness-fix-ready-promise
EmilianoSanchez Oct 27, 2025
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
9 changes: 8 additions & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
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)
- Bugfix - Update `debug` option to support log levels when `logger` option is used.

2.7.0 (October 7, 2025)
- Added support for custom loggers: added `logger` configuration option and `LoggerAPI.setLogger` method to allow the SDK to use a custom logger.
- Added support for custom loggers: added `logger` configuration option and `factory.Logger.setLogger` method to allow the SDK to use a custom logger.

2.6.0 (September 18, 2025)
- Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@splitsoftware/splitio-commons",
"version": "2.7.0",
"version": "2.7.1",
"description": "Split JavaScript SDK common components",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
2 changes: 1 addition & 1 deletion src/logger/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const SUBMITTERS_PUSH_PAGE_HIDDEN = 125;
export const ENGINE_VALUE_INVALID = 200;
export const ENGINE_VALUE_NO_ATTRIBUTES = 201;
export const CLIENT_NO_LISTENER = 202;
export const CLIENT_NOT_READY = 203;
export const CLIENT_NOT_READY_FROM_CACHE = 203;
export const SYNC_MYSEGMENTS_FETCH_RETRY = 204;
export const SYNC_SPLITS_FETCH_FAILS = 205;
export const STREAMING_PARSING_ERROR_FAILS = 206;
Expand Down
2 changes: 0 additions & 2 deletions src/logger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ export class Logger implements ILogger {
if (logger) {
if (isLogger(logger)) {
this.logger = logger;
// If custom logger is set, all logs are either enabled or disabled
if (this.logLevel !== LogLevelIndexes.NONE) this.setLogLevel(LogLevels.DEBUG);
return;
} else {
this.error('Invalid `logger` instance. It must be an object with `debug`, `info`, `warn` and `error` methods. Defaulting to `console.log`');
Expand Down
4 changes: 2 additions & 2 deletions src/logger/messages/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const codesWarn: [number, string][] = codesError.concat([
[c.SUBMITTERS_PUSH_FAILS, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Dropping %s after retry. Reason: %s.'],
[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, '%s: the SDK is not ready, 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_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_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
7 changes: 3 additions & 4 deletions src/readiness/readinessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ISettings } from '../types';
import SplitIO from '../../types/splitio';
import { SDK_SPLITS_ARRIVED, SDK_SPLITS_CACHE_LOADED, SDK_SEGMENTS_ARRIVED, SDK_READY_TIMED_OUT, SDK_READY_FROM_CACHE, SDK_UPDATE, SDK_READY } from './constants';
import { IReadinessEventEmitter, IReadinessManager, ISegmentsEventEmitter, ISplitsEventEmitter } from './types';
import { STORAGE_LOCALSTORAGE } from '../utils/constants';

function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter {
const splitsEventEmitter = objectAssign(new EventEmitter(), {
Expand Down Expand Up @@ -91,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 @@ -115,9 +114,9 @@ export function readinessManagerFactory(
isReady = true;
try {
syncLastUpdate();
if (!isReadyFromCache && settings.storage?.type === STORAGE_LOCALSTORAGE) {
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