From 1ad5b5062a3539b34538fc6c5b2a43fee582701e Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Tue, 13 Feb 2024 09:36:15 +0200 Subject: [PATCH] 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 --- frontend/src/utils/storage.test.ts | 76 +++++++++++++++--------------- frontend/src/utils/storage.ts | 50 +++++++++++++++----- 2 files changed, 78 insertions(+), 48 deletions(-) diff --git a/frontend/src/utils/storage.test.ts b/frontend/src/utils/storage.test.ts index c8aa3f510e..9197082843 100644 --- a/frontend/src/utils/storage.test.ts +++ b/frontend/src/utils/storage.test.ts @@ -53,13 +53,26 @@ Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock, }); -// Test suite describe('localStorage with TTL', () => { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); 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', () => { setLocalStorageItem('testKey', 'testValue', 600000); expect(getLocalStorageItem('testKey')).toBe('testValue'); @@ -82,47 +95,36 @@ describe('localStorage with TTL', () => { }>('testObjectKey'); expect(retrievedObject).toEqual(testObject); }); - - 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('should correctly store and retrieve a Map object', () => { + const testMap = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + setLocalStorageItem('testMap', testMap); + expect(getLocalStorageItem>('testMap')).toEqual(testMap); }); - test('object should be retrievable before TTL expires in sessionStorage', () => { - const testObject = { name: 'TestSession', number: 456 }; - setSessionStorageItem('testObjectKeySession', testObject, 500000); - - const retrievedObject = getSessionStorageItem( - 'testObjectKeySession', - ); - expect(retrievedObject).toEqual(testObject); + test('should correctly store and retrieve a Set object', () => { + const testSet = new Set(['value1', 'value2']); + setLocalStorageItem('testSet', testSet); + expect(getLocalStorageItem>('testSet')).toEqual(testSet); }); - test('object should not be retrievable after TTL expires in sessionStorage', () => { - const testObject = { name: 'TestSession', number: 456 }; - setSessionStorageItem('testObjectKeySession', testObject, 500000); // 10 minutes TTL - - vi.advanceTimersByTime(600000); - - const retrievedObject = getSessionStorageItem( - 'testObjectKeySession', - ); - expect(retrievedObject).toBeUndefined(); + test('should handle nested objects with arrays, Maps, and Sets', () => { + const complexObject = { + array: [1, 2, 3], + map: new Map([['nestedKey', 'nestedValue']]), + set: new Set([1, 2, 3]), + }; + setLocalStorageItem('complexObject', complexObject); + expect( + getLocalStorageItem('complexObject'), + ).toEqual(complexObject); }); - test('should handle set with any level of nesting', () => { - setLocalStorageItem( - 'testKey', - new Set([{ nestedSet: new Set([1, 2]) }]), - ); - expect(getLocalStorageItem('testKey')).toEqual([{ nestedSet: [1, 2] }]); + test('sessionStorage item should expire as per TTL', () => { + setSessionStorageItem('sessionTTL', 'expiring', 50); // 50ms TTL + vi.advanceTimersByTime(60); + expect(getSessionStorageItem('sessionTTL')).toBeUndefined(); }); }); diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index e54a75568d..c5873fd4fa 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -3,6 +3,37 @@ type Expirable = { 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. // Returns undefined if the browser denies access. export function getLocalStorageItem(key: string): T | undefined { @@ -39,12 +70,7 @@ export function setLocalStorageItem( ? new Date().getTime() + timeToLive : null, }; - window.localStorage.setItem( - key, - JSON.stringify(item, (_key, value) => - value instanceof Set ? [...value] : value, - ), - ); + window.localStorage.setItem(key, JSON.stringify(item, customReplacer)); } catch (err: unknown) { console.warn(err); } @@ -66,9 +92,7 @@ export function setSessionStorageItem( }; window.sessionStorage.setItem( key, - JSON.stringify(item, (_key, value) => - value instanceof Set ? [...value] : value, - ), + JSON.stringify(item, customReplacer), ); } catch (err: unknown) { console.warn(err); @@ -97,10 +121,14 @@ export function getSessionStorageItem(key: string): T | undefined { // Parse an item from localStorage. // Returns undefined if the item could not be parsed. -function parseStoredItem(data: string | null): T | undefined { +function parseStoredItem(data: string | null): Expirable | undefined { try { - return data ? JSON.parse(data) : undefined; + const item: Expirable = data + ? JSON.parse(data, deserializer) + : undefined; + return item; } catch (err: unknown) { console.warn(err); + return undefined; } }