1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: Make storing to local storage robust (#6139)

Handles non serializable objects like Map and Set and makes the
serialisation/deserialisation process more robust generally.

Closes #
[UNL-322](https://linear.app/unleash/issue/UNL-322/add-types-to-local-storage-and-force-simple-types)

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2024-02-13 09:36:15 +02:00 committed by GitHub
parent dc977d3f40
commit 1ad5b5062a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 78 additions and 48 deletions

View File

@ -53,13 +53,26 @@ Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock, value: sessionStorageMock,
}); });
// Test suite
describe('localStorage with TTL', () => { describe('localStorage with TTL', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.clear();
vi.useFakeTimers(); vi.useFakeTimers();
}); });
test('object should not be retrievable after TTL expires', () => {
const testObject = { name: 'Test', number: 123 };
setLocalStorageItem('testObjectKey', testObject, 500000);
vi.advanceTimersByTime(600000);
const retrievedObject = getLocalStorageItem<{
name: string;
number: number;
}>('testObjectKey');
expect(retrievedObject).toBeUndefined();
});
test('item should be retrievable before TTL expires', () => { test('item should be retrievable before TTL expires', () => {
setLocalStorageItem('testKey', 'testValue', 600000); setLocalStorageItem('testKey', 'testValue', 600000);
expect(getLocalStorageItem('testKey')).toBe('testValue'); expect(getLocalStorageItem('testKey')).toBe('testValue');
@ -82,47 +95,36 @@ describe('localStorage with TTL', () => {
}>('testObjectKey'); }>('testObjectKey');
expect(retrievedObject).toEqual(testObject); expect(retrievedObject).toEqual(testObject);
}); });
test('should correctly store and retrieve a Map object', () => {
test('object should not be retrievable after TTL expires', () => { const testMap = new Map([
const testObject = { name: 'Test', number: 123 }; ['key1', 'value1'],
setLocalStorageItem('testObjectKey', testObject, 500000); ['key2', 'value2'],
]);
vi.advanceTimersByTime(600000); setLocalStorageItem('testMap', testMap);
expect(getLocalStorageItem<Map<any, any>>('testMap')).toEqual(testMap);
const retrievedObject = getLocalStorageItem<{
name: string;
number: number;
}>('testObjectKey');
expect(retrievedObject).toBeUndefined();
}); });
test('object should be retrievable before TTL expires in sessionStorage', () => { test('should correctly store and retrieve a Set object', () => {
const testObject = { name: 'TestSession', number: 456 }; const testSet = new Set(['value1', 'value2']);
setSessionStorageItem('testObjectKeySession', testObject, 500000); setLocalStorageItem('testSet', testSet);
expect(getLocalStorageItem<Set<any>>('testSet')).toEqual(testSet);
const retrievedObject = getSessionStorageItem<typeof testObject>(
'testObjectKeySession',
);
expect(retrievedObject).toEqual(testObject);
}); });
test('object should not be retrievable after TTL expires in sessionStorage', () => { test('should handle nested objects with arrays, Maps, and Sets', () => {
const testObject = { name: 'TestSession', number: 456 }; const complexObject = {
setSessionStorageItem('testObjectKeySession', testObject, 500000); // 10 minutes TTL array: [1, 2, 3],
map: new Map([['nestedKey', 'nestedValue']]),
vi.advanceTimersByTime(600000); set: new Set([1, 2, 3]),
};
const retrievedObject = getSessionStorageItem<typeof testObject>( setLocalStorageItem('complexObject', complexObject);
'testObjectKeySession', expect(
); getLocalStorageItem<typeof complexObject>('complexObject'),
expect(retrievedObject).toBeUndefined(); ).toEqual(complexObject);
}); });
test('should handle set with any level of nesting', () => { test('sessionStorage item should expire as per TTL', () => {
setLocalStorageItem( setSessionStorageItem('sessionTTL', 'expiring', 50); // 50ms TTL
'testKey', vi.advanceTimersByTime(60);
new Set([{ nestedSet: new Set([1, 2]) }]), expect(getSessionStorageItem('sessionTTL')).toBeUndefined();
);
expect(getLocalStorageItem('testKey')).toEqual([{ nestedSet: [1, 2] }]);
}); });
}); });

View File

@ -3,6 +3,37 @@ type Expirable<T> = {
expiry: number | null; expiry: number | null;
}; };
function isFunction(value: any): boolean {
return typeof value === 'function';
}
function serializer(key: string, value: any): any {
if (isFunction(value)) {
console.warn('Unable to store function.');
return undefined;
}
if (value instanceof Map) {
return { dataType: 'Map', value: Array.from(value.entries()) };
} else if (value instanceof Set) {
return { dataType: 'Set', value: Array.from(value) };
}
return value;
}
function deserializer(key: string, value: any): any {
if (value && value.dataType === 'Map') {
return new Map(value.value);
} else if (value && value.dataType === 'Set') {
return new Set(value.value);
}
return value;
}
// Custom replacer for JSON.stringify to handle complex objects
function customReplacer(key: string, value: any): any {
return serializer(key, value);
}
// Get an item from localStorage. // Get an item from localStorage.
// Returns undefined if the browser denies access. // Returns undefined if the browser denies access.
export function getLocalStorageItem<T>(key: string): T | undefined { export function getLocalStorageItem<T>(key: string): T | undefined {
@ -39,12 +70,7 @@ export function setLocalStorageItem<T>(
? new Date().getTime() + timeToLive ? new Date().getTime() + timeToLive
: null, : null,
}; };
window.localStorage.setItem( window.localStorage.setItem(key, JSON.stringify(item, customReplacer));
key,
JSON.stringify(item, (_key, value) =>
value instanceof Set ? [...value] : value,
),
);
} catch (err: unknown) { } catch (err: unknown) {
console.warn(err); console.warn(err);
} }
@ -66,9 +92,7 @@ export function setSessionStorageItem<T>(
}; };
window.sessionStorage.setItem( window.sessionStorage.setItem(
key, key,
JSON.stringify(item, (_key, value) => JSON.stringify(item, customReplacer),
value instanceof Set ? [...value] : value,
),
); );
} catch (err: unknown) { } catch (err: unknown) {
console.warn(err); console.warn(err);
@ -97,10 +121,14 @@ export function getSessionStorageItem<T>(key: string): T | undefined {
// Parse an item from localStorage. // Parse an item from localStorage.
// Returns undefined if the item could not be parsed. // Returns undefined if the item could not be parsed.
function parseStoredItem<T>(data: string | null): T | undefined { function parseStoredItem<T>(data: string | null): Expirable<T> | undefined {
try { try {
return data ? JSON.parse(data) : undefined; const item: Expirable<T> = data
? JSON.parse(data, deserializer)
: undefined;
return item;
} catch (err: unknown) { } catch (err: unknown) {
console.warn(err); console.warn(err);
return undefined;
} }
} }