From 72ad8c40006971e86ec421fb9ff2ee9daa403766 Mon Sep 17 00:00:00 2001 From: Gerben Neven Date: Fri, 19 Jun 2020 14:43:23 +0200 Subject: [PATCH 1/4] add storage class to handle gracefull degration --- src/local-storage-events.ts | 5 +-- src/storage.ts | 64 +++++++++++++++++++++++++++++++++++ src/use-localstorage.ts | 9 ++--- test/index.test.tsx | 2 ++ test/storage.test.ts | 56 ++++++++++++++++++++++++++++++ test/use-localstorage.test.ts | 45 ++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 src/storage.ts create mode 100644 test/storage.test.ts diff --git a/src/local-storage-events.ts b/src/local-storage-events.ts index 3a9c095..c6d9816 100644 --- a/src/local-storage-events.ts +++ b/src/local-storage-events.ts @@ -1,3 +1,4 @@ +import { storage } from './storage' interface KVP { key: K, value: V @@ -49,7 +50,7 @@ export function isTypeOfLocalStorageChanged(evt: any): evt is LocalStora */ export function writeStorage(key: string, value: TValue) { try { - localStorage.setItem(key, typeof value === 'object' ? JSON.stringify(value) : `${value}`); + storage.setItem(key, typeof value === 'object' ? JSON.stringify(value) : `${value}`); window.dispatchEvent(new LocalStorageChanged({ key, value })); } catch (err) { if (err instanceof TypeError && err.message.includes('circular structure')) { @@ -86,6 +87,6 @@ export function writeStorage(key: string, value: TValue) { * @param {string} key The key of the item you wish to delete from localStorage. */ export function deleteFromStorage(key: string) { - localStorage.removeItem(key); + storage.removeItem(key); window.dispatchEvent(new LocalStorageChanged({ key, value: null })) } diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..1318207 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,64 @@ +/** + * Test if localStorage API is available + * From https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#Feature-detecting_localStorage + * @returns {boolean} + */ +export function localStorageAvailable(): boolean { + try { + var x = '@rehooks/local-storage:' + new Date().toISOString(); + localStorage.setItem(x, x); + localStorage.removeItem(x); + return true; + } + catch(e) { + return e instanceof DOMException && ( + // everything except Firefox + e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === 'QuotaExceededError' || + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + (localStorage && localStorage.length !== 0); + } +} + + +/** + * Provides a proxy to localStorage, returning default return values + * if `localStorage` is not available + */ +export class ProxyStorage { + available: boolean + + constructor() { + this.available = localStorageAvailable() + } + + getItem(key: string): string | null { + if (this.available === true) { + return localStorage.getItem(key) + } + return null + } + + setItem(key: string, value: string): void { + if (this.available === true) { + return localStorage.setItem(key, value) + } + return undefined + } + + removeItem(key: string): void { + if (this.available === true) { + return localStorage.removeItem(key) + } + return undefined + } +} + + +export const storage = new ProxyStorage() diff --git a/src/use-localstorage.ts b/src/use-localstorage.ts index a4adc02..c80f1b2 100644 --- a/src/use-localstorage.ts +++ b/src/use-localstorage.ts @@ -4,7 +4,8 @@ import { LocalStorageChanged, isTypeOfLocalStorageChanged, } from './local-storage-events'; -import { useEffect, useState, useCallback } from 'react'; +import { storage } from './storage' +import { useEffect, useState, useCallback, useRef } from 'react'; /** * This exists for trying to serialize the value back to JSON. @@ -53,9 +54,9 @@ export function useLocalStorage( defaultValue: TValue | null = null, ) { const [localState, updateLocalState] = useState( - localStorage.getItem(key) === null + storage.getItem(key) === null ? defaultValue - : tryParse(localStorage.getItem(key)!) + : tryParse(storage.getItem(key)!) ); const onLocalStorageChange = (event: LocalStorageChanged | StorageEvent) => { @@ -83,7 +84,7 @@ export function useLocalStorage( // Write default value to the local storage if there currently isn't any value there. // Don't however write a defaultValue that is null otherwise it'll trigger infinite updates. - if (localStorage.getItem(key) === null && defaultValue !== null) { + if (storage.getItem(key) === null && defaultValue !== null) { writeStorage(key, defaultValue); } diff --git a/test/index.test.tsx b/test/index.test.tsx index e241f2c..c435b02 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -3,6 +3,8 @@ import { useLocalStorage, writeStorage, deleteFromStorage } from '../src'; import { renderHook } from '@testing-library/react-hooks'; import { render, fireEvent, act, cleanup } from '@testing-library/react'; +import { storage } from '../src/storage' + afterEach(() => { cleanup(); diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..10c378d --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,56 @@ +import { ProxyStorage } from '../src/storage' + +describe('ProxyStorage', () => { + describe('when localStorage is available', () => { + // check assumption localStorage is available in tests + window.localStorage.setItem('hi', 'hi') + expect(window.localStorage.getItem('hi')).toEqual('hi') + + const storage = new ProxyStorage() + + it('initiates ProxyStorage as available', () => { + expect(storage.available).toEqual(true) + }) + + it('calls localStorage.setItem', () => { + storage.setItem('key1', 'value2') + expect(localStorage.getItem('key1')).toEqual('value2') + }) + + it('calls localStorage.getItem', () => { + localStorage.setItem('key2', 'value1') + expect(storage.getItem('key2')).toEqual('value1') + }) + + it('calls localStorage.removeItem', () => { + localStorage.setItem('key3', 'value1') + expect(storage.removeItem('key3')).toEqual(undefined) + expect(localStorage.getItem('key3')).toEqual(null) + }) + }) + + describe('when localStorage is not available', () => { + // check assumption localStorage is available in tests + window.localStorage.setItem('hi', 'hi') + expect(window.localStorage.getItem('hi')).toEqual('hi') + + const storage = new ProxyStorage() + storage.available = false + + it('returns default instead of calling localStorage.setItem', () => { + expect(storage.setItem('key4', 'value2')).toEqual(undefined) + expect(localStorage.getItem('key4')).toEqual(null) + }) + + it('returns default instead of calling localStorage.getItem', () => { + localStorage.setItem('key5', 'value1') + expect(storage.getItem('key5')).toEqual(null) + }) + + it('returns default instead of calling localStorage.getItem', () => { + localStorage.setItem('key6', 'value1') + expect(storage.removeItem('key6')).toEqual(undefined) + expect(localStorage.getItem('key6')).toEqual('value1') + }) + }) +}) diff --git a/test/use-localstorage.test.ts b/test/use-localstorage.test.ts index c29f26c..0da1977 100644 --- a/test/use-localstorage.test.ts +++ b/test/use-localstorage.test.ts @@ -1,6 +1,8 @@ import { useLocalStorage, deleteFromStorage } from '../src'; import { renderHook, act } from '@testing-library/react-hooks'; +import { storage } from '../src/storage' + describe('Module: use-localstorage', () => { describe('useLocalStorage', () => { it('is callable', () => { @@ -130,5 +132,48 @@ describe('Module: use-localstorage', () => { }); }); }); + + describe("when localStorage api is disabled", () => { + beforeAll(() => storage.available = false) + + afterAll(() => storage.available = true) + + it('should return default value', () => { + + const key = 'car'; + const defaultValue = 'beamer' + + const { result } = renderHook(() => useLocalStorage(key, defaultValue)) + + expect(result.current[0]).toBe(defaultValue) + }) + + it('still tracks state in localStorage', () => { + const key = 'car'; + const defaultValue = 'beamer' + + const { result } = renderHook(() => useLocalStorage(key, defaultValue)) + + expect(result.current[0]).toBe(defaultValue) + + act(() => result.current[1]('merc')) + expect(result.current[0]).toEqual('merc') + expect(localStorage.getItem('car')).toEqual(null) + }) + + it('defaults back to defaultValue in localState when deleted', () => { + const key = 'unavailableAPI'; + const defaultValue = 'localStorage' + + const { result } = renderHook(() => useLocalStorage(key, defaultValue)) + + expect(result.current[0]).toBe(defaultValue) + + act(() => result.current[1]('webrtc')) + expect(result.current[0]).toEqual('webrtc') + act(() => deleteFromStorage('unavailableAPI')) + expect(result.current[0]).toEqual(defaultValue) + }) + }) }); }); From e93df03d1c841c07d69d1d98612e179b0998ff2b Mon Sep 17 00:00:00 2001 From: Gerben Neven Date: Wed, 2 Sep 2020 15:16:50 +0100 Subject: [PATCH 2/4] refactor storage to use IProxyStorage --- src/storage.ts | 48 ++++++++++++++++++++--------------- test/storage.test.ts | 11 +++----- test/use-localstorage.test.ts | 6 ----- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 1318207..477cb85 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -27,38 +27,44 @@ export function localStorageAvailable(): boolean { } -/** - * Provides a proxy to localStorage, returning default return values - * if `localStorage` is not available - */ -export class ProxyStorage { - available: boolean +interface IProxyStorage { + getItem(key: string): string | null + setItem(Key: string, value: string): void + removeItem(key: string): void +} + +export class LocalStorageProxy implements IProxyStorage { + getItem(key: string): string | null { + return localStorage.getItem(key) + } - constructor() { - this.available = localStorageAvailable() + setItem(key: string, value: string): void { + localStorage.setItem(key, value) + } + + removeItem(key: string): void { + localStorage.removeItem(key) } +} + +export class MemoryStorageProxy implements IProxyStorage { + private _memoryStorage = new Map() getItem(key: string): string | null { - if (this.available === true) { - return localStorage.getItem(key) - } - return null + return this._memoryStorage.get(key) ?? null } setItem(key: string, value: string): void { - if (this.available === true) { - return localStorage.setItem(key, value) - } - return undefined + this._memoryStorage.set(key, value) } removeItem(key: string): void { - if (this.available === true) { - return localStorage.removeItem(key) - } - return undefined + this._memoryStorage.delete(key) } } +const proxyStorageFrom = (isAvailable: boolean) => isAvailable + ? new LocalStorageProxy() + : new MemoryStorageProxy() -export const storage = new ProxyStorage() +export const storage = proxyStorageFrom(localStorageAvailable()) diff --git a/test/storage.test.ts b/test/storage.test.ts index 10c378d..354d929 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,4 +1,4 @@ -import { ProxyStorage } from '../src/storage' +import { MemoryStorageProxy, LocalStorageProxy } from '../src/storage' describe('ProxyStorage', () => { describe('when localStorage is available', () => { @@ -6,11 +6,7 @@ describe('ProxyStorage', () => { window.localStorage.setItem('hi', 'hi') expect(window.localStorage.getItem('hi')).toEqual('hi') - const storage = new ProxyStorage() - - it('initiates ProxyStorage as available', () => { - expect(storage.available).toEqual(true) - }) + const storage = new LocalStorageProxy() it('calls localStorage.setItem', () => { storage.setItem('key1', 'value2') @@ -34,8 +30,7 @@ describe('ProxyStorage', () => { window.localStorage.setItem('hi', 'hi') expect(window.localStorage.getItem('hi')).toEqual('hi') - const storage = new ProxyStorage() - storage.available = false + const storage = new MemoryStorageProxy() it('returns default instead of calling localStorage.setItem', () => { expect(storage.setItem('key4', 'value2')).toEqual(undefined) diff --git a/test/use-localstorage.test.ts b/test/use-localstorage.test.ts index 0da1977..e8499c2 100644 --- a/test/use-localstorage.test.ts +++ b/test/use-localstorage.test.ts @@ -1,8 +1,6 @@ import { useLocalStorage, deleteFromStorage } from '../src'; import { renderHook, act } from '@testing-library/react-hooks'; -import { storage } from '../src/storage' - describe('Module: use-localstorage', () => { describe('useLocalStorage', () => { it('is callable', () => { @@ -134,10 +132,6 @@ describe('Module: use-localstorage', () => { }); describe("when localStorage api is disabled", () => { - beforeAll(() => storage.available = false) - - afterAll(() => storage.available = true) - it('should return default value', () => { const key = 'car'; From abe4cd8d5fa5044f3a602b7a7e3405ffe2d06ad6 Mon Sep 17 00:00:00 2001 From: Gerben Neven Date: Fri, 11 Sep 2020 15:15:59 +0100 Subject: [PATCH 3/4] mock storage implementation for integration tests --- test/use-localstorage.test.ts | 207 +++++++++++++++++++--------------- 1 file changed, 113 insertions(+), 94 deletions(-) diff --git a/test/use-localstorage.test.ts b/test/use-localstorage.test.ts index e8499c2..c92d1e0 100644 --- a/test/use-localstorage.test.ts +++ b/test/use-localstorage.test.ts @@ -1,137 +1,156 @@ import { useLocalStorage, deleteFromStorage } from '../src'; import { renderHook, act } from '@testing-library/react-hooks'; -describe('Module: use-localstorage', () => { - describe('useLocalStorage', () => { - it('is callable', () => { - const { result } = renderHook(() => useLocalStorage('foo', 'bar')); - expect(result.current).toBeDefined(); - }); +jest.mock('../src/storage', () => ({ + ...jest.requireActual('../src/storage'), + storage: jest.fn(), +})); - it('accepts non-JSON strings', () => { - const key = 'name'; - const defaultValue = 'bond'; - localStorage.setItem(key, defaultValue); +import { storage, LocalStorageProxy, MemoryStorageProxy } from '../src/storage' - const { result } = renderHook(() => useLocalStorage(key)); +describe('Module: use-localstorage', () => { - expect(result.current[0]).toBe(defaultValue); - }); + describe('useLocalStorage', () => { + describe('LocalStorage API enabled', () => { + beforeAll(() => { + // @ts-ignore + storage = new LocalStorageProxy() + }) - it('returns a javascript object if it finds a JSON string', () => { - const key = '🛸🛸🛸🛸🛸'; - const value = { _: 'a', 3: true, z: { y: [2] } }; - localStorage.setItem(key, JSON.stringify(value)); + it('is callable', () => { + const { result } = renderHook(() => useLocalStorage('foo', 'bar')); + expect(result.current).toBeDefined(); + }); - const { result } = renderHook(() => useLocalStorage(key)); + it('accepts non-JSON strings', () => { + const key = 'name'; + const defaultValue = 'bond'; + localStorage.setItem(key, defaultValue); - expect(result.current[0]).toEqual(value); - }); + const { result } = renderHook(() => useLocalStorage(key)); + expect(result.current[0]).toBe(defaultValue); + }); - it('does not override existing data', () => { - const key = `dynamickey-` + Date.now(); - const firstDefaultValue = Date.now(); + it('returns a javascript object if it finds a JSON string', () => { + const key = '🛸🛸🛸🛸🛸'; + const value = { _: 'a', 3: true, z: { y: [2] } }; + localStorage.setItem(key, JSON.stringify(value)); - // first call of the hook - const { result } = renderHook(() => useLocalStorage(key, firstDefaultValue)); - expect(result.current[0]).toBe(firstDefaultValue); - expect(parseInt(localStorage.getItem(key)!)).toBe(firstDefaultValue); - // second render. as the value already set, default value - // should not override existing value. - const { result: result2 } = renderHook(() => useLocalStorage(key, Date())); + const { result } = renderHook(() => useLocalStorage(key)); - const [lastValue] = result2.current; + expect(result.current[0]).toEqual(value); + }); - expect(lastValue).toEqual(firstDefaultValue); - expect(parseInt(localStorage.getItem(key)!)).toBe(firstDefaultValue); - }); - it('can have a numeric default value', () => { - const key = 'Numberwang'; - const defaultValue = 42; - const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + it('does not override existing data', () => { + const key = `dynamickey-` + Date.now(); + const firstDefaultValue = Date.now(); - expect(result.current[0]).toBe(defaultValue); - expect(parseInt(localStorage.getItem(key)!)).toBe(defaultValue); - }); + // first call of the hook + const { result } = renderHook(() => useLocalStorage(key, firstDefaultValue)); + expect(result.current[0]).toBe(firstDefaultValue); + expect(parseInt(localStorage.getItem(key)!)).toBe(firstDefaultValue); + // second render. as the value already set, default value + // should not override existing value. + const { result: result2 } = renderHook(() => useLocalStorage(key, Date())); - it('can have a default value of 0', async () => { - const key = 'AmountOfMoneyInMyBankAccount'; - const defaultValue = 0; - const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const [lastValue] = result2.current; - expect(result.current[0]).toBe(defaultValue); - expect(localStorage.getItem(key)).toBe(`${defaultValue}`); - }); + expect(lastValue).toEqual(firstDefaultValue); + expect(parseInt(localStorage.getItem(key)!)).toBe(firstDefaultValue); + }); - describe('when existing value is false', () => { - it('returns false value when the default value is true', () => { - const key = 'AmIFalse'; - const defaultValue = true; + it('can have a numeric default value', () => { + const key = 'Numberwang'; + const defaultValue = 42; + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - localStorage.setItem(key, 'false'); + expect(result.current[0]).toBe(defaultValue); + expect(parseInt(localStorage.getItem(key)!)).toBe(defaultValue); + }); + it('can have a default value of 0', async () => { + const key = 'AmountOfMoneyInMyBankAccount'; + const defaultValue = 0; const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - expect(result.current[0]).toBe(false); - expect(JSON.parse(localStorage.getItem(key)!)).toBe(false); + expect(result.current[0]).toBe(defaultValue); + expect(localStorage.getItem(key)).toBe(`${defaultValue}`); }); - it('returns false value when default value is false', () => { - const key = 'AmIFalse'; - const defaultValue = false; + describe('when existing value is false', () => { + it('returns false value when the default value is true', () => { + const key = 'AmIFalse'; + const defaultValue = true; - localStorage.setItem(key, 'false'); + localStorage.setItem(key, 'false'); - const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - expect(result.current[0]).toBe(false); - expect(JSON.parse(localStorage.getItem(key)!)).toBe(false); - }); - }); + expect(result.current[0]).toBe(false); + expect(JSON.parse(localStorage.getItem(key)!)).toBe(false); + }); - describe('when a default value is given and deleteFromStorage is called', () => { - describe('current value', () => { - it('becomes the default value', async () => { - const key = 'profile'; - const defaultValue = { firstName: 'Corona', lastName: 'Virus', url: 'https://iam.co/vid?q=19' }; - const newValue: typeof defaultValue = { firstName: 'Pro', lastName: 'Test', url: 'https://www.professionaltesting.com' }; - const { result } = renderHook( - () => useLocalStorage(key, defaultValue) - ); + it('returns false value when default value is false', () => { + const key = 'AmIFalse'; + const defaultValue = false; - expect(result.current[0]).toBe(defaultValue); + localStorage.setItem(key, 'false'); - act(() => result.current[1](newValue)); - expect(result.current[0]).toBe(newValue); + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - act(() => deleteFromStorage(key)); - expect(result.current[0]).toBe(defaultValue); + expect(result.current[0]).toBe(false); + expect(JSON.parse(localStorage.getItem(key)!)).toBe(false); }); }); - describe('the value in localStorage', () => { - it('is null', async () => { - const key = '<<>>'; - const defaultValue = 'i'; - const newValue = 'o'; - const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - - expect(result.current[0]).toBe(defaultValue); - expect(localStorage.getItem(key)).toBe(defaultValue); - act(() => result.current[1](newValue)); - expect(localStorage.getItem(key)).toBe(newValue); - expect(result.current[0]).toBe(newValue); + describe('when a default value is given and deleteFromStorage is called', () => { + describe('current value', () => { + it('becomes the default value', async () => { + const key = 'profile'; + const defaultValue = { firstName: 'Corona', lastName: 'Virus', url: 'https://iam.co/vid?q=19' }; + const newValue: typeof defaultValue = { firstName: 'Pro', lastName: 'Test', url: 'https://www.professionaltesting.com' }; + const { result } = renderHook( + () => useLocalStorage(key, defaultValue) + ); - act(() => result.current[2]()); - expect(localStorage.getItem(key)).toBe(null); - expect(result.current[0]).toBe(defaultValue); + expect(result.current[0]).toBe(defaultValue); + + act(() => result.current[1](newValue)); + expect(result.current[0]).toBe(newValue); + + act(() => deleteFromStorage(key)); + expect(result.current[0]).toBe(defaultValue); + }); + }); + describe('the value in localStorage', () => { + it('is null', async () => { + const key = '<<>>'; + const defaultValue = 'i'; + const newValue = 'o'; + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + + expect(result.current[0]).toBe(defaultValue); + expect(localStorage.getItem(key)).toBe(defaultValue); + + act(() => result.current[1](newValue)); + expect(localStorage.getItem(key)).toBe(newValue); + expect(result.current[0]).toBe(newValue); + + act(() => result.current[2]()); + expect(localStorage.getItem(key)).toBe(null); + expect(result.current[0]).toBe(defaultValue); + }); }); }); - }); + }) describe("when localStorage api is disabled", () => { + beforeAll(() => { + // @ts-ignore + storage = new MemoryStorageProxy() + }) it('should return default value', () => { const key = 'car'; From 7f4d315b63dbcdfcac22e9e7a0b2910eef87a44d Mon Sep 17 00:00:00 2001 From: Gerben Neven Date: Mon, 16 Nov 2020 09:45:51 +0100 Subject: [PATCH 4/4] remove unused useRef from src/use-localstorage.ts Co-authored-by: Pier-Luc Gendreau --- src/use-localstorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/use-localstorage.ts b/src/use-localstorage.ts index c80f1b2..4a99ed8 100644 --- a/src/use-localstorage.ts +++ b/src/use-localstorage.ts @@ -5,7 +5,7 @@ import { isTypeOfLocalStorageChanged, } from './local-storage-events'; import { storage } from './storage' -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback } from 'react'; /** * This exists for trying to serialize the value back to JSON.