Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
2.11.0 (January XX, 2026)
- Added metadata to SDK_UPDATE events to indicate the type of update (FLAGS_UPDATE or SEGMENTS_UPDATE) and the names of updated flags or segments.
- Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean indicating if SDK was loaded from cache) and `lastUpdateTimestamp` (Int64 milliseconds since epoch).
- Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean: `true` for fresh install/first app launch, `false` for warm cache/second app launch) and `lastUpdateTimestamp` (milliseconds since epoch).

2.10.1 (December 18, 2025)
- Bugfix - Handle `null` prerequisites properly.
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.10.2-rc.4",
"version": "2.10.2-rc.5",
"description": "Split JavaScript SDK common components",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
49 changes: 22 additions & 27 deletions src/readiness/__tests__/readinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,13 @@ test('READINESS MANAGER / Ready from cache event should be fired once', (done) =
counter++;
});

readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
setTimeout(() => {
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
}, 0);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });

setTimeout(() => {
expect(counter).toBe(1); // should be called only once
Expand Down Expand Up @@ -366,19 +364,21 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', ()
test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when cache is loaded', () => {
const readinessManager = readinessManagerFactory(EventEmitter, settings);

const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
let receivedMetadata: SdkReadyMetadata | undefined;
readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => {
receivedMetadata = meta;
});

// Emit cache loaded event
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
// Emit cache loaded event with timestamp
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, {
initialCacheLoad: false,
lastUpdateTimestamp: cacheTimestamp
});

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true);
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(false);
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
});

test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SDK becomes ready without cache', () => {
Expand All @@ -394,17 +394,16 @@ test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SD
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(false);
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(true);
expect(receivedMetadata!.lastUpdateTimestamp).toBeUndefined();
});

test('READINESS MANAGER / SDK_READY should emit with metadata when ready from cache', () => {
const readinessManager = readinessManagerFactory(EventEmitter, settings);

// First emit cache loaded
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
// First emit cache loaded with timestamp
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: cacheTimestamp });

let receivedMetadata: SdkReadyMetadata | undefined;
readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => {
Expand All @@ -416,10 +415,8 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready from ca
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was ready from cache first
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was ready from cache first
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
});

test('READINESS MANAGER / SDK_READY should emit with metadata when ready without cache', () => {
Expand All @@ -435,8 +432,6 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready without
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was not ready from cache
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was not ready from cache
expect(receivedMetadata!.lastUpdateTimestamp).toBeUndefined(); // No cache timestamp when fresh install
});
2 changes: 1 addition & 1 deletion src/readiness/__tests__/sdkReadinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ describe('SDK Readiness Manager - Promises', () => {
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);

// make the SDK ready from cache
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: null });
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false);

// validate error log for SDK_READY_FROM_CACHE
Expand Down
24 changes: 11 additions & 13 deletions src/readiness/readinessManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { objectAssign } from '../utils/lang/objectAssign';
import { ISettings } from '../types';
import SplitIO from '../../types/splitio';
import SplitIO, { SdkReadyMetadata } 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';

Expand Down Expand Up @@ -55,6 +55,7 @@ export function readinessManagerFactory(

// emit SDK_READY_FROM_CACHE
let isReadyFromCache = false;
let cacheLastUpdateTimestamp: number | undefined = undefined;
if (splits.splitsCacheLoaded) isReadyFromCache = true; // ready from cache, but doesn't emit SDK_READY_FROM_CACHE
else splits.once(SDK_SPLITS_CACHE_LOADED, checkIsReadyFromCache);

Expand Down Expand Up @@ -84,17 +85,14 @@ export function readinessManagerFactory(
splits.initCallbacks.push(__init);
if (splits.hasInit) __init();

function checkIsReadyFromCache() {
function checkIsReadyFromCache(cacheMetadata: SdkReadyMetadata) {
cacheLastUpdateTimestamp = cacheMetadata.lastUpdateTimestamp;
isReadyFromCache = true;
// Don't emit SDK_READY_FROM_CACHE if SDK_READY has been emitted
if (!isReady && !isDestroyed) {
try {
syncLastUpdate();
const metadata: SplitIO.SdkReadyMetadata = {
initialCacheLoad: true,
lastUpdateTimestamp: lastUpdate
};
gate.emit(SDK_READY_FROM_CACHE, metadata);
gate.emit(SDK_READY_FROM_CACHE, cacheMetadata);
} catch (e) {
// throws user callback exceptions in next tick
setTimeout(() => { throw e; }, 0);
Expand All @@ -121,15 +119,15 @@ export function readinessManagerFactory(
const wasReadyFromCache = isReadyFromCache;
if (!isReadyFromCache) {
isReadyFromCache = true;
const metadataFromCache: SplitIO.SdkReadyMetadata = {
initialCacheLoad: false,
lastUpdateTimestamp: lastUpdate
const metadataReadyFromCache: SplitIO.SdkReadyMetadata = {
initialCacheLoad: true, // Fresh install, no cache existed
lastUpdateTimestamp: undefined // No cache timestamp when fresh install
};
gate.emit(SDK_READY_FROM_CACHE, metadataFromCache);
gate.emit(SDK_READY_FROM_CACHE, metadataReadyFromCache);
}
const metadataReady: SplitIO.SdkReadyMetadata = {
initialCacheLoad: wasReadyFromCache,
lastUpdateTimestamp: lastUpdate
initialCacheLoad: !wasReadyFromCache, // true if not ready from cache (initial load), false if ready from cache
lastUpdateTimestamp: wasReadyFromCache ? cacheLastUpdateTimestamp : undefined
};
gate.emit(SDK_READY, metadataReady);
} catch (e) {
Expand Down
6 changes: 3 additions & 3 deletions src/sdkFactory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
return;
}
readiness.splits.emit(SDK_SPLITS_ARRIVED);
readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
},
onReadyFromCacheCb() {
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
}
});

const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.fallbackTreatments);

if (initialRolloutPlan) {
setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key));
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
}

const clients: Record<string, SplitIO.IBasicClient> = {};
Expand Down
42 changes: 30 additions & 12 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ describe.each(storages)('validateCache', (storage) => {
for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string);
});

test('if there is no cache, it should return false', async () => {
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
test('if there is no cache, it should return initialCacheLoad: true', async () => {
const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -44,12 +46,16 @@ describe.each(storages)('validateCache', (storage) => {
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it must not be cleared, it should return true', async () => {
test('if there is cache and it must not be cleared, it should return initialCacheLoad: false', async () => {
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
await storage.save && storage.save();

expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.initialCacheLoad).toBe(false);
expect(result.lastUpdateTimestamp).toBe(lastUpdateTimestamp);

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -63,13 +69,15 @@ describe.each(storages)('validateCache', (storage) => {
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it has expired, it should clear cache and return false', async () => {
test('if there is cache and it has expired, it should clear cache and return initialCacheLoad: true', async () => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
await storage.save && storage.save();

expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
const result = await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');

Expand All @@ -82,12 +90,14 @@ describe.each(storages)('validateCache', (storage) => {
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and its hash has changed, it should clear cache and return false', async () => {
test('if there is cache and its hash has changed, it should clear cache and return initialCacheLoad: true', async () => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
const result = await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments);
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');

Expand All @@ -100,14 +110,16 @@ describe.each(storages)('validateCache', (storage) => {
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => {
test('if there is cache and clearOnInit is true, it should clear cache and return initialCacheLoad: true', async () => {
// Older cache version (without last clear)
storage.removeItem(keys.buildLastClear());
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
const result = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');

Expand All @@ -122,14 +134,20 @@ describe.each(storages)('validateCache', (storage) => {

// If cache is cleared, it should not clear again until a day has passed
logSpy.mockClear();
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
storage.setItem(keys.buildSplitsTillKey(), '1');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
const result2 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result2.initialCacheLoad).toBe(false);
expect(result2.lastUpdateTimestamp).toBe(lastUpdateTimestamp);
expect(logSpy).not.toHaveBeenCalled();
expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed

// If a day has passed, it should clear again
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
const result3 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result3.initialCacheLoad).toBe(true);
expect(result3.lastUpdateTimestamp).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
expect(splits.clear).toHaveBeenCalledTimes(2);
expect(rbSegments.clear).toHaveBeenCalledTimes(2);
Expand Down
7 changes: 5 additions & 2 deletions src/storages/inLocalStorage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
const rbSegments = new RBSegmentsCacheInLocal(settings, keys, storage);
const segments = new MySegmentsCacheInLocal(log, keys, storage);
const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage);
let validateCachePromise: Promise<boolean> | undefined;
let validateCachePromise: Promise<SplitIO.SdkReadyMetadata> | undefined;

return {
splits,
Expand All @@ -68,7 +68,10 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
uniqueKeys: new UniqueKeysCacheInMemoryCS(),

validateCache() {
return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments));
if (!validateCachePromise) {
validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments);
}
return validateCachePromise;
},

save() {
Expand Down
Loading