Skip to content
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
- Added two new configuration options for the SDK's `LOCALSTORAGE` storage type to control the behavior of the persisted rollout plan cache in the browser:
- `storage.expirationDays` to specify the validity period of the rollout plan cache in days.
- `storage.clearOnInit` to clear the rollout plan cache on SDK initialization.
- Updated SDK_READY_FROM_CACHE event when using the `LOCALSTORAGE` storage type to be emitted alongside the SDK_READY event if it has not already been emitted.

11.1.0 (January 17, 2025)
- Added support for the new impressions tracking toggle available on feature flags, both respecting the setting and including the new field being returned on `SplitView` type objects. Read more in our docs.
Expand Down
96 changes: 47 additions & 49 deletions src/__tests__/browserSuites/ready-from-cache.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function (fetchMock, assert) {
events: 'https://events.baseurl/readyFromCacheEmpty'
};
localStorage.clear();
t.plan(3);
t.plan(4);

fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=-1', { status: 200, body: splitChangesMock1 });
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=1457552620999', { status: 200, body: splitChangesMock2 });
Expand Down Expand Up @@ -124,18 +124,17 @@ export default function (fetchMock, assert) {
t.end();
});
client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if there is no cache.');
t.end();
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.on(client.Event.SDK_READY, () => {
t.pass('It should emit SDK_READY alone, since there was no cache.');
t.true(client.__getStatus().isReadyFromCache, 'Client should emit SDK_READY and it should be ready from cache');
});
client2.on(client.Event.SDK_READY, () => {
t.pass('It should emit SDK_READY alone, since there was no cache.');
t.true(client2.__getStatus().isReadyFromCache, 'Non-default client should emit SDK_READY and it should be ready from cache');
});
client3.on(client.Event.SDK_READY, () => {
t.pass('It should emit SDK_READY alone, since there was no cache.');
t.true(client2.__getStatus().isReadyFromCache, 'Non-default client should emit SDK_READY and it should be ready from cache');
});

});
Expand All @@ -148,17 +147,17 @@ export default function (fetchMock, assert) {
localStorage.clear();
t.plan(12 * 2 + 3);

fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=25', function () {
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=25', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { ...splitChangesMock1, since: 25 }, headers: {} }), 200); }); // 400ms is how long it'll take to reply with Splits, no SDK_READY should be emitted before that.
});
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=1457552620999', { status: 200, body: splitChangesMock2 });
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: membershipsNicolas, headers: {} }), 400); }); // First client gets segments before splits. No segment cache loading (yet)
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 700); }); // Second client gets segments after 700ms
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 1000); }); // Third client memberships will come after 1s
});
fetchMock.postOnce(testUrls.events + '/testImpressions/bulk', 200);
Expand Down Expand Up @@ -255,18 +254,18 @@ export default function (fetchMock, assert) {
localStorage.clear();
t.plan(12 * 2 + 5);

fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=25', function () {
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=25', () => {
t.equal(localStorage.getItem('readyFromCache_3.SPLITIO.split.always_on'), alwaysOnSplitInverted, 'feature flags must not be cleaned from cache');
return new Promise(res => { setTimeout(() => res({ status: 200, body: { ...splitChangesMock1, since: 25 }, headers: {} }), 200); }); // 400ms is how long it'll take to reply with Splits, no SDK_READY should be emitted before that.
});
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=1457552620999', { status: 200, body: splitChangesMock2 });
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: membershipsNicolas, headers: {} }), 400); }); // First client gets segments before splits. No segment cache loading (yet)
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 700); }); // Second client gets segments after 700ms
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', function () {
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 1000); }); // Third client memberships will come after 1s
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas4%40split.io', { 'ms': {} });
Expand Down Expand Up @@ -365,28 +364,30 @@ export default function (fetchMock, assert) {
});

assert.test(t => { // Testing when we start with cached data but expired (lastUpdate timestamp lower than custom (1) expirationDays ago)
const CLIENT_READY_MS = 400, CLIENT2_READY_MS = 700, CLIENT3_READY_MS = 1000;

const testUrls = {
sdk: 'https://sdk.baseurl/readyFromCacheWithData4',
events: 'https://events.baseurl/readyFromCacheWithData4'
};
localStorage.clear();

fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=-1', function () {
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=-1', () => {
t.equal(localStorage.getItem('some_user_item'), 'user_item', 'user items at localStorage must not be changed');
t.equal(localStorage.getItem('readyFromCache_4.SPLITIO.hash'), expectedHashNullFilter, 'storage hash must not be changed');
t.true(nearlyEqual(parseInt(localStorage.getItem('readyFromCache_4.SPLITIO.lastClear'), 10), Date.now()), 'storage lastClear timestamp must be updated');
t.equal(localStorage.length, 3, 'feature flags cache data must be cleaned from localStorage');
return { status: 200, body: splitChangesMock1 };
});
fetchMock.get(testUrls.sdk + '/splitChanges?s=1.2&since=1457552620999', { status: 200, body: splitChangesMock2 });
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', function () {
return new Promise(res => { setTimeout(() => res({ status: 200, body: membershipsNicolas, headers: {} }), 400); }); // First client gets segments before splits. No segment cache loading (yet)
fetchMock.get(testUrls.sdk + '/memberships/nicolas%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: membershipsNicolas, headers: {} }), CLIENT_READY_MS); }); // First client gets segments before splits. No segment cache loading (yet)
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', function () {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 700); }); // Second client gets segments after 700ms
fetchMock.get(testUrls.sdk + '/memberships/nicolas2%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), CLIENT2_READY_MS); }); // Second client gets segments after 700ms
});
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', function () {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), 1000); }); // Third client memberships will come after 1s
fetchMock.get(testUrls.sdk + '/memberships/nicolas3%40split.io', () => {
return new Promise(res => { setTimeout(() => res({ status: 200, body: { 'ms': {} }, headers: {} }), CLIENT3_READY_MS); }); // Third client memberships will come after 1s
});
fetchMock.postOnce(testUrls.events + '/testImpressions/bulk', 200);
fetchMock.postOnce(testUrls.events + '/testImpressions/count', 200);
Expand Down Expand Up @@ -423,37 +424,34 @@ export default function (fetchMock, assert) {
});

client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if there is expired cache.');
t.end();
t.true(nearlyEqual(Date.now() - startTime, CLIENT_READY_MS), 'It should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});
client2.once(client2.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if there is expired cache.');
t.end();
t.true(nearlyEqual(Date.now() - startTime, CLIENT2_READY_MS), 'It should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});
client3.once(client3.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if there is expired cache.');
t.end();
t.true(nearlyEqual(Date.now() - startTime, CLIENT3_READY_MS), 'It should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.on(client.Event.SDK_READY, () => {
t.true(Date.now() - startTime >= 400, 'It should emit SDK_READY after syncing with the cloud.');
t.true(nearlyEqual(Date.now() - startTime, CLIENT_READY_MS), 'It should emit SDK_READY after syncing with the cloud.');
t.equal(client.getTreatment('always_on'), 'on', 'It should evaluate treatments with updated data after syncing with the cloud.');
});
client.ready().then(() => {
t.true(Date.now() - startTime >= 400, 'It should resolve ready promise after syncing with the cloud.');
t.true(nearlyEqual(Date.now() - startTime, CLIENT_READY_MS), 'It should resolve ready promise after syncing with the cloud.');
t.equal(client.getTreatment('always_on'), 'on', 'It should evaluate treatments with updated data after syncing with the cloud.');
});
client2.on(client2.Event.SDK_READY, () => {
t.true(Date.now() - startTime >= 700, 'It should emit SDK_READY after syncing with the cloud.');
t.true(nearlyEqual(Date.now() - startTime, CLIENT2_READY_MS), 'It should emit SDK_READY after syncing with the cloud.');
t.equal(client2.getTreatment('always_on'), 'on', 'It should evaluate treatments with updated data after syncing with the cloud.');
});
client2.ready().then(() => {
t.true(Date.now() - startTime >= 700, 'It should resolve ready promise after syncing with the cloud.');
t.true(nearlyEqual(Date.now() - startTime, CLIENT2_READY_MS), 'It should resolve ready promise after syncing with the cloud.');
t.equal(client2.getTreatment('always_on'), 'on', 'It should evaluate treatments with updated data after syncing with the cloud.');
});
client3.on(client3.Event.SDK_READY, () => {
client3.ready().then(() => {
t.true(Date.now() - startTime >= 1000, 'It should resolve ready promise after syncing with the cloud.');
t.true(nearlyEqual(Date.now() - startTime, CLIENT3_READY_MS), 'It should resolve ready promise after syncing with the cloud.');
t.equal(client3.getTreatment('always_on'), 'on', 'It should evaluate treatments with updated data after syncing with the cloud.');

// Last cb: destroy clients and check that localstorage has the expected items
Expand Down Expand Up @@ -486,7 +484,7 @@ export default function (fetchMock, assert) {
events: 'https://events.baseurl/readyFromCache_5'
};
localStorage.clear();
t.plan(7);
t.plan(8);

fetchMock.getOnce(testUrls.sdk + '/splitChanges?s=1.2&since=-1&names=p1__split,p2__split', { status: 200, body: { splits: [splitDeclarations.p1__split, splitDeclarations.p2__split], since: -1, till: 1457552620999 } }, { delay: 10 }); // short delay to let emit SDK_READY_FROM_CACHE
fetchMock.getOnce(testUrls.sdk + '/memberships/nicolas%40split.io', { status: 200, body: { ms: {} } });
Expand All @@ -512,8 +510,7 @@ export default function (fetchMock, assert) {
const manager = splitio.manager();

client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE because localStorage is cleaned and there isn\'t cached feature flags');
t.end();
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.once(client.Event.SDK_READY, () => {
Expand All @@ -537,7 +534,7 @@ export default function (fetchMock, assert) {
events: 'https://events.baseurl/readyFromCache_5B'
};
localStorage.clear();
t.plan(5);
t.plan(6);

fetchMock.getOnce(testUrls.sdk + '/splitChanges?s=1.2&since=-1&names=p1__split,p2__split', { status: 200, body: { splits: [splitDeclarations.p1__split, splitDeclarations.p2__split], since: -1, till: 1457552620999 } }, { delay: 10 }); // short delay to let emit SDK_READY_FROM_CACHE
fetchMock.getOnce(testUrls.sdk + '/memberships/nicolas%40split.io', { status: 200, body: { ms: {} } });
Expand All @@ -557,8 +554,7 @@ export default function (fetchMock, assert) {
const manager = splitio.manager();

client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if cache is empty.');
t.end();
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.once(client.Event.SDK_READY, () => {
Expand Down Expand Up @@ -630,7 +626,7 @@ export default function (fetchMock, assert) {
events: 'https://events.baseurl/readyFromCache_7'
};
localStorage.clear();
t.plan(6);
t.plan(7);

fetchMock.getOnce(testUrls.sdk + '/splitChanges?s=1.2&since=-1&prefixes=p1,p2', { status: 200, body: { splits: [splitDeclarations.p1__split, splitDeclarations.p2__split], since: -1, till: 1457552620999 } }, { delay: 10 }); // short delay to let emit SDK_READY_FROM_CACHE
fetchMock.getOnce(testUrls.sdk + '/memberships/nicolas%40split.io', { status: 200, body: { ms: {} } });
Expand Down Expand Up @@ -659,8 +655,7 @@ export default function (fetchMock, assert) {
const manager = splitio.manager();

client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE if cache has expired.');
t.end();
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.once(client.Event.SDK_READY, () => {
Expand Down Expand Up @@ -696,7 +691,7 @@ export default function (fetchMock, assert) {
events: 'https://events.baseurl/readyFromCache_8'
};
localStorage.clear();
t.plan(7);
t.plan(8);

fetchMock.getOnce(testUrls.sdk + '/splitChanges?s=1.2&since=-1', { status: 200, body: { splits: [splitDeclarations.p1__split, splitDeclarations.p2__split, splitDeclarations.p3__split], since: -1, till: 1457552620999 } }, { delay: 10 }); // short delay to let emit SDK_READY_FROM_CACHE
fetchMock.getOnce(testUrls.sdk + '/memberships/nicolas%40split.io', { status: 200, body: { ms: {} } });
Expand All @@ -721,8 +716,7 @@ export default function (fetchMock, assert) {
const manager = splitio.manager();

client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.fail('It should not emit SDK_READY_FROM_CACHE because all feature flags were removed from cache since the filter query changed.');
t.end();
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY');
});

client.once(client.Event.SDK_READY, () => {
Expand Down Expand Up @@ -823,10 +817,12 @@ export default function (fetchMock, assert) {

t.true(console.log.calledWithMatch('clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'), 'It should log a message about cleaning up cache');

client.once(client.Event.SDK_READY_FROM_CACHE, () => t.fail('It should not emit SDK_READY_FROM_CACHE because clearOnInit is true.'));
client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY, because clearOnInit is true');
});

await client.ready();
t.equal(manager.names().sort().length, 32, 'active splits should be present for evaluation');
t.equal(manager.names().sort().length, 33, 'active splits should be present for evaluation');

await splitio.destroy();
t.equal(localStorage.getItem('readyFromCache_10.SPLITIO.splits.till'), '1457552620999', 'splits.till must correspond to the till of the last successfully fetched Splits');
Expand All @@ -843,7 +839,7 @@ export default function (fetchMock, assert) {

await new Promise(res => client.once(client.Event.SDK_READY_FROM_CACHE, res));

t.equal(manager.names().sort().length, 32, 'active splits should be present for evaluation');
t.equal(manager.names().sort().length, 33, 'active splits should be present for evaluation');
t.false(console.log.calledWithMatch('clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'), 'It should log a message about cleaning up cache');

await splitio.destroy();
Expand All @@ -858,11 +854,13 @@ export default function (fetchMock, assert) {
client = splitio.client();
manager = splitio.manager();

client.once(client.Event.SDK_READY_FROM_CACHE, () => t.fail('It should not emit SDK_READY_FROM_CACHE because clearOnInit is true.'));
client.once(client.Event.SDK_READY_FROM_CACHE, () => {
t.true(client.__getStatus().isReady, 'Client should emit SDK_READY_FROM_CACHE alongside SDK_READY, because clearOnInit is true');
});

await new Promise(res => client.once(client.Event.SDK_READY, res));

t.equal(manager.names().sort().length, 32, 'active splits should be present for evaluation');
t.equal(manager.names().sort().length, 33, 'active splits should be present for evaluation');
t.true(console.log.calledWithMatch('clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'), 'It should log a message about cleaning up cache');

await splitio.destroy();
Expand Down