Skip to content

Commit d5cdf7f

Browse files
Merge pull request #419 from splitio/inlocalstorage_async_validateCache
[Custom InLocalStorage] Refactor storage `validateCache` method to support async operations
2 parents 134869e + 3d3578b commit d5cdf7f

File tree

9 files changed

+88
-70
lines changed

9 files changed

+88
-70
lines changed

CHANGES.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@
4747
- Removed internal ponyfills for `Map` and `Set` global objects, dropping support for IE and other outdated browsers. The SDK now requires the runtime environment to support these features natively or to provide a polyfill.
4848
- Removed the `sync.localhostMode` configuration option to plug the LocalhostMode module.
4949

50+
1.17.1 (July 25, 2025)
51+
- Updated the Redis storage to avoid lazy require of the `ioredis` dependency when the SDK is initialized.
52+
- Updated some transitive dependencies for vulnerability fixes.
53+
- Bugfix - Enhanced HTTP client module to implement timeouts for failing requests that might otherwise remain pending indefinitely on some Fetch API implementations, pausing the SDK synchronization process.
54+
- Bugfix - Properly handle rejected promises when using targeting rules with segment matchers in consumer modes (e.g., Redis and Pluggable storages).
55+
- Bugfix - Sanitize the `SplitSDKMachineName` header value to avoid exceptions on HTTP/S requests when it contains non ISO-8859-1 characters (Related to issue https://github.com/splitio/javascript-client/issues/847).
56+
- Bugfix - Fixed an issue with the SDK_UPDATE event on server-side, where it was not being emitted if there was an empty segment and the SDK received a feature flag update notification.
57+
- Bugfix - Fixed an issue with the server-side polling manager that caused dangling timers when the SDK was destroyed before it was ready.
58+
5059
1.17.0 (September 6, 2024)
5160
- Added `sync.requestOptions.getHeaderOverrides` configuration option to enhance SDK HTTP request Headers for Authorization Frameworks.
5261
- Added `isTimedout` and `lastUpdate` properties to IStatusInterface to keep track of the timestamp of the last SDK event, used on React and Redux SDKs.

src/storages/inLocalStorage/__tests__/validateCache.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ describe('validateCache', () => {
2828
localStorage.clear();
2929
});
3030

31-
test('if there is no cache, it should return false', () => {
32-
expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
31+
test('if there is no cache, it should return false', async () => {
32+
expect(await validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
3333

3434
expect(logSpy).not.toHaveBeenCalled();
3535

@@ -43,11 +43,11 @@ describe('validateCache', () => {
4343
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
4444
});
4545

46-
test('if there is cache and it must not be cleared, it should return true', () => {
46+
test('if there is cache and it must not be cleared, it should return true', async () => {
4747
localStorage.setItem(keys.buildSplitsTillKey(), '1');
4848
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
4949

50-
expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
50+
expect(await validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
5151

5252
expect(logSpy).not.toHaveBeenCalled();
5353

@@ -61,12 +61,12 @@ describe('validateCache', () => {
6161
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
6262
});
6363

64-
test('if there is cache and it has expired, it should clear cache and return false', () => {
64+
test('if there is cache and it has expired, it should clear cache and return false', async () => {
6565
localStorage.setItem(keys.buildSplitsTillKey(), '1');
6666
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
6767
localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
6868

69-
expect(validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
69+
expect(await validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
7070

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

@@ -79,11 +79,11 @@ describe('validateCache', () => {
7979
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
8080
});
8181

82-
test('if there is cache and its hash has changed, it should clear cache and return false', () => {
82+
test('if there is cache and its hash has changed, it should clear cache and return false', async () => {
8383
localStorage.setItem(keys.buildSplitsTillKey(), '1');
8484
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
8585

86-
expect(validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
86+
expect(await validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
8787

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

@@ -96,12 +96,12 @@ describe('validateCache', () => {
9696
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
9797
});
9898

99-
test('if there is cache and clearOnInit is true, it should clear cache and return false', () => {
99+
test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => {
100100
// Older cache version (without last clear)
101101
localStorage.setItem(keys.buildSplitsTillKey(), '1');
102102
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
103103

104-
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
104+
expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
105105

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

@@ -117,13 +117,13 @@ describe('validateCache', () => {
117117
// If cache is cleared, it should not clear again until a day has passed
118118
logSpy.mockClear();
119119
localStorage.setItem(keys.buildSplitsTillKey(), '1');
120-
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
120+
expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
121121
expect(logSpy).not.toHaveBeenCalled();
122122
expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
123123

124124
// If a day has passed, it should clear again
125125
localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
126-
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
126+
expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
127127
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
128128
expect(splits.clear).toHaveBeenCalledTimes(2);
129129
expect(rbSegments.clear).toHaveBeenCalledTimes(2);

src/storages/inLocalStorage/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
4040
const rbSegments = new RBSegmentsCacheInLocal(settings, keys);
4141
const segments = new MySegmentsCacheInLocal(log, keys);
4242
const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey));
43+
let validateCachePromise: Promise<boolean> | undefined;
4344

4445
return {
4546
splits,
@@ -53,10 +54,12 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
5354
uniqueKeys: new UniqueKeysCacheInMemoryCS(),
5455

5556
validateCache() {
56-
return validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments);
57+
return validateCachePromise || (validateCachePromise = validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments));
5758
},
5859

59-
destroy() { },
60+
destroy() {
61+
return Promise.resolve();
62+
},
6063

6164
// When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key).
6265
shared(matchingKey: string) {

src/storages/inLocalStorage/validateCache.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,29 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS
6767
*
6868
* @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache)
6969
*/
70-
export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean {
70+
export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise<boolean> {
7171

72-
const currentTimestamp = Date.now();
73-
const isThereCache = splits.getChangeNumber() > -1;
72+
return Promise.resolve().then(() => {
73+
const currentTimestamp = Date.now();
74+
const isThereCache = splits.getChangeNumber() > -1;
7475

75-
if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) {
76-
splits.clear();
77-
rbSegments.clear();
78-
segments.clear();
79-
largeSegments.clear();
76+
if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) {
77+
splits.clear();
78+
rbSegments.clear();
79+
segments.clear();
80+
largeSegments.clear();
8081

81-
// Update last clear timestamp
82-
try {
83-
localStorage.setItem(keys.buildLastClear(), currentTimestamp + '');
84-
} catch (e) {
85-
settings.log.error(LOG_PREFIX + e);
86-
}
82+
// Update last clear timestamp
83+
try {
84+
localStorage.setItem(keys.buildLastClear(), currentTimestamp + '');
85+
} catch (e) {
86+
settings.log.error(LOG_PREFIX + e);
87+
}
8788

88-
return false;
89-
}
89+
return false;
90+
}
9091

91-
// Check if ready from cache
92-
return isThereCache;
92+
// Check if ready from cache
93+
return isThereCache;
94+
});
9395
}

src/storages/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ export interface IStorageSync extends IStorageBase<
479479
IUniqueKeysCacheSync
480480
> {
481481
// Defined in client-side
482-
validateCache?: () => boolean, // @TODO support async
482+
validateCache?: () => Promise<boolean>,
483483
largeSegments?: ISegmentsCacheSync,
484484
}
485485

src/sync/__tests__/syncManagerOnline.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const pushManagerMock = {
4343
// Mocked pushManager
4444
const pushManagerFactoryMock = jest.fn(() => pushManagerMock);
4545

46-
test('syncManagerOnline should start or not the submitter depending on user consent status', () => {
46+
test('syncManagerOnline should start or not the submitter depending on user consent status', async () => {
4747
const settings = { ...fullSettings };
4848

4949
const syncManager = syncManagerOnlineFactory()({
@@ -52,14 +52,14 @@ test('syncManagerOnline should start or not the submitter depending on user cons
5252
});
5353
const submitterManager = syncManager.submitterManager!;
5454

55-
syncManager.start();
55+
await syncManager.start();
5656
expect(submitterManager.start).toBeCalledTimes(1);
5757
expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is undefined
5858
syncManager.stop();
5959
expect(submitterManager.stop).toBeCalledTimes(1);
6060

6161
settings.userConsent = 'UNKNOWN';
62-
syncManager.start();
62+
await syncManager.start();
6363
expect(submitterManager.start).toBeCalledTimes(2);
6464
expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is unknown
6565
syncManager.stop();
@@ -69,7 +69,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons
6969
expect(submitterManager.execute).lastCalledWith(true); // SubmitterManager should flush only telemetry, if userConsent is unknown
7070

7171
settings.userConsent = 'GRANTED';
72-
syncManager.start();
72+
await syncManager.start();
7373
expect(submitterManager.start).toBeCalledTimes(3);
7474
expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is granted
7575
syncManager.stop();
@@ -79,7 +79,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons
7979
expect(submitterManager.execute).lastCalledWith(false); // SubmitterManager should flush all submitters, if userConsent is granted
8080

8181
settings.userConsent = 'DECLINED';
82-
syncManager.start();
82+
await syncManager.start();
8383
expect(submitterManager.start).toBeCalledTimes(4);
8484
expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is declined
8585
syncManager.stop();
@@ -90,7 +90,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons
9090

9191
});
9292

93-
test('syncManagerOnline should syncAll a single time when sync is disabled', () => {
93+
test('syncManagerOnline should syncAll a single time when sync is disabled', async () => {
9494
const settings = { ...fullSettings };
9595

9696
// disable sync
@@ -106,19 +106,19 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', ()
106106
expect(pushManagerFactoryMock).not.toBeCalled();
107107

108108
// Test pollingManager for Main client
109-
syncManager.start();
109+
await syncManager.start();
110110

111111
expect(pollingManagerMock.start).not.toBeCalled();
112112
expect(pollingManagerMock.syncAll).toBeCalledTimes(1);
113113

114114
syncManager.stop();
115-
syncManager.start();
115+
await syncManager.start();
116116

117117
expect(pollingManagerMock.start).not.toBeCalled();
118118
expect(pollingManagerMock.syncAll).toBeCalledTimes(1);
119119

120120
syncManager.stop();
121-
syncManager.start();
121+
await syncManager.start();
122122

123123
expect(pollingManagerMock.start).not.toBeCalled();
124124
expect(pollingManagerMock.syncAll).toBeCalledTimes(1);
@@ -139,12 +139,12 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', ()
139139

140140
pollingSyncManagerShared.stop();
141141

142-
syncManager.start();
142+
await syncManager.start();
143143

144144
expect(pollingManagerMock.start).not.toBeCalled();
145145

146146
syncManager.stop();
147-
syncManager.start();
147+
await syncManager.start();
148148

149149
expect(pollingManagerMock.start).not.toBeCalled();
150150

@@ -175,7 +175,7 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', ()
175175
expect(pushManagerFactoryMock).toBeCalled();
176176

177177
// Test pollingManager for Main client
178-
testSyncManager.start();
178+
await testSyncManager.start();
179179

180180
expect(pushManagerMock.start).toBeCalled();
181181

src/sync/offline/syncTasks/fromObjectSyncTask.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ export function fromObjectUpdaterFactory(
5959

6060
if (startingUp) {
6161
startingUp = false;
62-
const isCacheLoaded = storage.validateCache ? storage.validateCache() : false;
63-
Promise.resolve().then(() => {
62+
Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => {
6463
// Emits SDK_READY_FROM_CACHE
6564
if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
6665
// Emits SDK_READY

src/sync/syncManagerOnline.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,36 +89,41 @@ export function syncManagerOnlineFactory(
8989
start() {
9090
running = true;
9191

92-
if (startFirstTime) {
93-
const isCacheLoaded = storage.validateCache ? storage.validateCache() : false;
94-
if (isCacheLoaded) Promise.resolve().then(() => { readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); });
95-
}
92+
// @TODO once event, impression and telemetry storages support persistence, call when `validateCache` promise is resolved
93+
submitterManager.start(!isConsentGranted(settings));
9694

97-
// start syncing splits and segments
98-
if (pollingManager) {
95+
return Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => {
96+
if (!running) return;
9997

100-
// If synchronization is disabled pushManager and pollingManager should not start
101-
if (syncEnabled) {
102-
if (pushManager) {
103-
// Doesn't call `syncAll` when the syncManager is resuming
98+
if (startFirstTime) {
99+
// Emits SDK_READY_FROM_CACHE
100+
if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
101+
102+
}
103+
104+
// start syncing splits and segments
105+
if (pollingManager) {
106+
107+
// If synchronization is disabled pushManager and pollingManager should not start
108+
if (syncEnabled) {
109+
if (pushManager) {
110+
// Doesn't call `syncAll` when the syncManager is resuming
111+
if (startFirstTime) {
112+
pollingManager.syncAll();
113+
}
114+
pushManager.start();
115+
} else {
116+
pollingManager.start();
117+
}
118+
} else {
104119
if (startFirstTime) {
105120
pollingManager.syncAll();
106121
}
107-
pushManager.start();
108-
} else {
109-
pollingManager.start();
110-
}
111-
} else {
112-
if (startFirstTime) {
113-
pollingManager.syncAll();
114122
}
115123
}
116-
}
117-
118-
// start periodic data recording (events, impressions, telemetry).
119-
submitterManager.start(!isConsentGranted(settings));
120124

121-
startFirstTime = false;
125+
startFirstTime = false;
126+
});
122127
},
123128

124129
/**

src/utils/settingsValidation/storage/storageCS.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { IStorageFactoryParams, IStorageSync } from '../../../storages/types';
88

99
export function __InLocalStorageMockFactory(params: IStorageFactoryParams): IStorageSync {
1010
const result = InMemoryStorageCSFactory(params);
11-
result.validateCache = () => true; // to emit SDK_READY_FROM_CACHE
11+
result.validateCache = () => Promise.resolve(true); // to emit SDK_READY_FROM_CACHE
1212
return result;
1313
}
1414
__InLocalStorageMockFactory.type = STORAGE_MEMORY;

0 commit comments

Comments
 (0)