diff --git a/src/services/ExternalStore.ts b/src/services/ExternalStore.ts index 7097a84e5c..34b65821e9 100644 --- a/src/services/ExternalStore.ts +++ b/src/services/ExternalStore.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from 'react' type Listener = () => void +type Undefinable = T | undefined // Singleton with getter/setter whose hook triggers a re-render class ExternalStore { @@ -15,9 +16,9 @@ class ExternalStore { return this.store } - public readonly setStore = (value: T): void => { + public readonly setStore = (value: Undefinable | ((oldVal: Undefinable) => Undefinable)): void => { if (value !== this.store) { - this.store = value + this.store = value instanceof Function ? value(this.store) : value this.listeners.forEach((listener) => listener()) } } diff --git a/src/services/local-storage/__tests__/useLocalStorage.test.ts b/src/services/local-storage/__tests__/useLocalStorage.test.ts index 9639045783..b530416189 100644 --- a/src/services/local-storage/__tests__/useLocalStorage.test.ts +++ b/src/services/local-storage/__tests__/useLocalStorage.test.ts @@ -8,7 +8,8 @@ describe('useLocalStorage', () => { }) it('should set the value', () => { - const { result } = renderHook(() => useLocalStorage('test-key')) + const key = Math.random().toString(32) + const { result } = renderHook(() => useLocalStorage(key)) const [value, setValue] = result.current expect(value).toBe(undefined) @@ -27,7 +28,8 @@ describe('useLocalStorage', () => { }) it('should set the value using a callback', () => { - const { result } = renderHook(() => useLocalStorage('test-key')) + const key = Math.random().toString(32) + const { result } = renderHook(() => useLocalStorage(key)) const [value, setValue] = result.current expect(value).toBe(undefined) @@ -46,9 +48,10 @@ describe('useLocalStorage', () => { }) it('should read from LS on initial call', () => { - local.setItem('test-key', 'ls') + const key = Math.random().toString(32) + local.setItem(key, 'ls') - const { result } = renderHook(() => useLocalStorage('test-key')) + const { result } = renderHook(() => useLocalStorage(key)) expect(result.current[0]).toBe('ls') }) diff --git a/src/services/local-storage/useLocalStorage.ts b/src/services/local-storage/useLocalStorage.ts index 9005ec1991..a33a38b4b0 100644 --- a/src/services/local-storage/useLocalStorage.ts +++ b/src/services/local-storage/useLocalStorage.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' +import ExternalStore from '../ExternalStore' import local from './local' // The setter accepts T or a function that takes the old value and returns T @@ -7,61 +8,62 @@ type Undefinable = T | undefined type Setter = (val: T | ((prevVal: Undefinable) => Undefinable)) => void +// External stores for each localStorage key which act as a shared cache for LS +const externalStores: Record> = {} + const useLocalStorage = (key: string): [Undefinable, Setter] => { - const [cache, setCache] = useState>() + if (!externalStores[key]) { + externalStores[key] = new ExternalStore() + } + const { getStore, setStore, useStore } = externalStores[key] as ExternalStore // This is the setter that will be returned // It will update the local storage and cache const setNewValue = useCallback>( (value) => { - setCache((oldValue) => { + setStore((oldValue) => { const newValue = value instanceof Function ? value(oldValue) : value if (newValue !== oldValue) { local.setItem(key, newValue) - - // Dispatch a fake storage event within the current browser tab - // The real storage event is dispatched only in other tabs - window.dispatchEvent( - new StorageEvent('storage', { - key: local.getPrefixedKey(key), - }), - ) } return newValue }) }, - [key], + [key, setStore], ) - // Subscribe to changes in local storage and update the cache - // This will work across tabs + // Set the initial value from LS on mount useEffect(() => { - const syncCache = () => { + if (getStore() === undefined) { const lsValue = local.getItem(key) if (lsValue !== null) { - setCache(lsValue) + setStore(lsValue) } } + }, [key, getStore, setStore]) + // Subscribe to changes in local storage and update the cache + // This will work across tabs + useEffect(() => { const onStorageEvent = (event: StorageEvent) => { if (event.key === local.getPrefixedKey(key)) { - syncCache() + const lsValue = local.getItem(key) + if (lsValue !== null && lsValue !== getStore()) { + setStore(lsValue) + } } } - // Set the initial value - syncCache() - window.addEventListener('storage', onStorageEvent) return () => { window.removeEventListener('storage', onStorageEvent) } - }, [key]) + }, [key, getStore, setStore]) - return [cache, setNewValue] + return [useStore(), setNewValue] } export default useLocalStorage