Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
2.8.1 (November 25, 2025)
- Updated the order of storage operations to prevent inconsistent states when using the `LOCALSTORAGE` storage type and the browser’s `localStorage` fails due to quota limits.

2.8.0 (October 30, 2025)
- Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs.
- Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc).
Expand Down
4 changes: 0 additions & 4 deletions src/readiness/__tests__/readinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,10 @@ import { EventEmitter } from '../../utils/MinEvents';
import { IReadinessManager } from '../types';
import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants';
import { ISettings } from '../../types';
import { STORAGE_LOCALSTORAGE } from '../../utils/constants';

const settings = {
startup: {
readyTimeout: 0,
},
storage: {
type: STORAGE_LOCALSTORAGE
}
} as unknown as ISettings;

Expand Down
46 changes: 26 additions & 20 deletions src/storages/AbstractMySegmentsCacheSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,10 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync
* For client-side synchronizer: it resets or updates the cache.
*/
resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean {
this.setChangeNumber(segmentsData.cn);

let isDiff = false;
const { added, removed } = segmentsData as MySegmentsData;

if (added && removed) {
let isDiff = false;

added.forEach(segment => {
isDiff = this.addSegment(segment) || isDiff;
Expand All @@ -63,32 +61,40 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync
removed.forEach(segment => {
isDiff = this.removeSegment(segment) || isDiff;
});
} else {

return isDiff;
}
const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort();
const storedSegmentKeys = this.getRegisteredSegments().sort();

const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort();
const storedSegmentKeys = this.getRegisteredSegments().sort();
// Extreme fast => everything is empty
if (!names.length && !storedSegmentKeys.length) {
isDiff = false;
} else {

// Extreme fast => everything is empty
if (!names.length && !storedSegmentKeys.length) return false;
let index = 0;

let index = 0;
while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++;

while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++;
// Quick path => no changes
if (index === names.length && index === storedSegmentKeys.length) {
isDiff = false;
} else {

// Quick path => no changes
if (index === names.length && index === storedSegmentKeys.length) return false;
// Slowest path => add and/or remove segments
for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) {
this.removeSegment(storedSegmentKeys[removeIndex]);
}

// Slowest path => add and/or remove segments
for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) {
this.removeSegment(storedSegmentKeys[removeIndex]);
}
for (let addIndex = index; addIndex < names.length; addIndex++) {
this.addSegment(names[addIndex]);
}

for (let addIndex = index; addIndex < names.length; addIndex++) {
this.addSegment(names[addIndex]);
isDiff = true;
}
}
}

return true;
this.setChangeNumber(segmentsData.cn);
return isDiff;
}
}
5 changes: 3 additions & 2 deletions src/storages/AbstractSplitsCacheSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync {
protected abstract setChangeNumber(changeNumber: number): boolean | void

update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean {
let updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result);
updated = toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated;
this.setChangeNumber(changeNumber);
const updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result);
return toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated;
return updated;
}

abstract getSplit(name: string): ISplit | null
Expand Down
42 changes: 19 additions & 23 deletions src/storages/inLocalStorage/MySegmentsCacheInLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync';
import type { MySegmentsKeyBuilder } from '../KeyBuilderCS';
import { LOG_PREFIX, DEFINED } from './constants';
import { StorageAdapter } from '../types';
import { MySegmentsData } from '../../sync/polling/types';
import { IMySegmentsResponse } from '../../dtos/types';

export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync {

Expand All @@ -16,33 +18,22 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync {
this.log = log;
this.keys = keys;
this.storage = storage;
// There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments
}

protected addSegment(name: string): boolean {
const segmentKey = this.keys.buildSegmentNameKey(name);

try {
if (this.storage.getItem(segmentKey) === DEFINED) return false;
this.storage.setItem(segmentKey, DEFINED);
return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
if (this.storage.getItem(segmentKey) === DEFINED) return false;
this.storage.setItem(segmentKey, DEFINED);
return true;
}

protected removeSegment(name: string): boolean {
const segmentKey = this.keys.buildSegmentNameKey(name);

try {
if (this.storage.getItem(segmentKey) !== DEFINED) return false;
this.storage.removeItem(segmentKey);
return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
if (this.storage.getItem(segmentKey) !== DEFINED) return false;
this.storage.removeItem(segmentKey);
return true;
}

isInSegment(name: string): boolean {
Expand All @@ -63,12 +54,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync {
}

protected setChangeNumber(changeNumber?: number) {
try {
if (changeNumber) this.storage.setItem(this.keys.buildTillKey(), changeNumber + '');
else this.storage.removeItem(this.keys.buildTillKey());
} catch (e) {
this.log.error(e);
}
if (changeNumber) this.storage.setItem(this.keys.buildTillKey(), changeNumber + '');
else this.storage.removeItem(this.keys.buildTillKey());
}

getChangeNumber() {
Expand All @@ -84,4 +71,13 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync {
return n;
}

resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse) {
try {
return super.resetSegments(segmentsData);
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
}

}
45 changes: 18 additions & 27 deletions src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync {
}

update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean {
let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result);
updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated;
this.setChangeNumber(changeNumber);
const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result);
return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated;
return updated;
}

private setChangeNumber(changeNumber: number) {
Expand All @@ -48,40 +49,30 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync {
}

private add(rbSegment: IRBSegment): boolean {
try {
const name = rbSegment.name;
const rbSegmentKey = this.keys.buildRBSegmentKey(name);
const rbSegmentFromStorage = this.storage.getItem(rbSegmentKey);
const previous = rbSegmentFromStorage ? JSON.parse(rbSegmentFromStorage) : null;
const name = rbSegment.name;
const rbSegmentKey = this.keys.buildRBSegmentKey(name);
const rbSegmentFromStorage = this.storage.getItem(rbSegmentKey);
const previous = rbSegmentFromStorage ? JSON.parse(rbSegmentFromStorage) : null;

this.storage.setItem(rbSegmentKey, JSON.stringify(rbSegment));
this.storage.setItem(rbSegmentKey, JSON.stringify(rbSegment));

let usesSegmentsDiff = 0;
if (previous && usesSegments(previous)) usesSegmentsDiff--;
if (usesSegments(rbSegment)) usesSegmentsDiff++;
if (usesSegmentsDiff !== 0) this.updateSegmentCount(usesSegmentsDiff);
let usesSegmentsDiff = 0;
if (previous && usesSegments(previous)) usesSegmentsDiff--;
if (usesSegments(rbSegment)) usesSegmentsDiff++;
if (usesSegmentsDiff !== 0) this.updateSegmentCount(usesSegmentsDiff);

return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
return true;
}

private remove(name: string): boolean {
try {
const rbSegment = this.get(name);
if (!rbSegment) return false;
const rbSegment = this.get(name);
if (!rbSegment) return false;

this.storage.removeItem(this.keys.buildRBSegmentKey(name));
this.storage.removeItem(this.keys.buildRBSegmentKey(name));

if (usesSegments(rbSegment)) this.updateSegmentCount(-1);
if (usesSegments(rbSegment)) this.updateSegmentCount(-1);

return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
return true;
}

private getNames(): string[] {
Expand Down
51 changes: 22 additions & 29 deletions src/storages/inLocalStorage/SplitsCacheInLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,44 +80,34 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync {
}

addSplit(split: ISplit) {
try {
const name = split.name;
const splitKey = this.keys.buildSplitKey(name);
const splitFromStorage = this.storage.getItem(splitKey);
const previousSplit = splitFromStorage ? JSON.parse(splitFromStorage) : null;

if (previousSplit) {
this._decrementCounts(previousSplit);
this.removeFromFlagSets(previousSplit.name, previousSplit.sets);
}
const name = split.name;
const splitKey = this.keys.buildSplitKey(name);
const splitFromStorage = this.storage.getItem(splitKey);
const previousSplit = splitFromStorage ? JSON.parse(splitFromStorage) : null;

if (previousSplit) {
this._decrementCounts(previousSplit);
this.removeFromFlagSets(previousSplit.name, previousSplit.sets);
}

this.storage.setItem(splitKey, JSON.stringify(split));
this.storage.setItem(splitKey, JSON.stringify(split));

this._incrementCounts(split);
this.addToFlagSets(split);
this._incrementCounts(split);
this.addToFlagSets(split);

return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
return true;
}

removeSplit(name: string): boolean {
try {
const split = this.getSplit(name);
if (!split) return false;
const split = this.getSplit(name);
if (!split) return false;

this.storage.removeItem(this.keys.buildSplitKey(name));
this.storage.removeItem(this.keys.buildSplitKey(name));

this._decrementCounts(split);
this.removeFromFlagSets(split.name, split.sets);
this._decrementCounts(split);
this.removeFromFlagSets(split.name, split.sets);

return true;
} catch (e) {
this.log.error(LOG_PREFIX + e);
return false;
}
return true;
}

getSplit(name: string): ISplit | null {
Expand Down Expand Up @@ -206,6 +196,9 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync {
const flagSetFromStorage = this.storage.getItem(flagSetKey);

const flagSetCache = new Set(flagSetFromStorage ? JSON.parse(flagSetFromStorage) : []);

if (flagSetCache.has(featureFlag.name)) return;

flagSetCache.add(featureFlag.name);

this.storage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,16 @@ test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => {
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1');
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null);
});

test('SEGMENT CACHE / Special case: localStorage failure should not throw an exception', () => {
const cache = new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user2'), localStorage);

// mock localStorage failure
const setItemSpy = jest.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('localStorage failure'); });
setItemSpy.mockClear();

expect(cache.resetSegments({ k: [{ n: 'mocked-segment' }, { n: 'mocked-segment-2' }], cn: 123 })).toBe(false);
expect(setItemSpy).toHaveBeenCalledTimes(1);

setItemSpy.mockRestore();
});
5 changes: 5 additions & 0 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe.each(storages)('validateCache', (storage) => {
test('if there is cache and it must not be cleared, it should return true', async () => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);

Expand All @@ -66,6 +67,7 @@ describe.each(storages)('validateCache', (storage) => {
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);

Expand All @@ -83,6 +85,7 @@ describe.each(storages)('validateCache', (storage) => {
test('if there is cache and its hash has changed, it should clear cache and return false', 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);

Expand All @@ -99,8 +102,10 @@ describe.each(storages)('validateCache', (storage) => {

test('if there is cache and clearOnInit is true, it should clear cache and return false', 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);

Expand Down
5 changes: 3 additions & 2 deletions src/storages/inMemory/RBSegmentsCacheInMemory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ export class RBSegmentsCacheInMemory implements IRBSegmentsCacheSync {
}

update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean {
let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result);
updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated;
this.changeNumber = changeNumber;
const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result);
return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated;
return updated;
}

private add(rbSegment: IRBSegment): boolean {
Expand Down
Loading