Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDKS-9171] Emit SDK_READY_FROM_CACHE event alongside SDK_READY event in case it has not been emitted #854

Merged
merged 9 commits into from
Jan 17, 2025
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