diff --git a/src/useLocalStorageValue/__docs__/example.stories.tsx b/src/useLocalStorageValue/__docs__/example.stories.tsx index f39c2564..d23255a8 100644 --- a/src/useLocalStorageValue/__docs__/example.stories.tsx +++ b/src/useLocalStorageValue/__docs__/example.stories.tsx @@ -10,25 +10,15 @@ interface ExampleProps { * LocalStorage key to manage. */ key: string; - /** - * Subscribe to window's `storage` event. - */ - handleStorageEvent: boolean; - /** - * Isolate hook from others on page - it will not receive updates from other hooks with the same key. - */ - isolated: boolean; } export const Example: React.FC = ({ key = 'react-hookz-ls-test', defaultValue = '@react-hookz is awesome', - handleStorageEvent = true, - isolated = false, }) => { - const [value, setValue, removeValue] = useLocalStorageValue(key, defaultValue, { - handleStorageEvent, - isolated, + const lsVal = useLocalStorageValue(key, { + defaultValue, + initializeWithValue: true, }); return ( @@ -40,12 +30,12 @@ export const Example: React.FC = ({
{ - setValue(ev.currentTarget.value); + lsVal.set(ev.currentTarget.value); }} - />{' '} - + /> + ); }; diff --git a/src/useLocalStorageValue/__docs__/story.mdx b/src/useLocalStorageValue/__docs__/story.mdx index e052cdae..aba7440d 100644 --- a/src/useLocalStorageValue/__docs__/story.mdx +++ b/src/useLocalStorageValue/__docs__/story.mdx @@ -1,6 +1,6 @@ -import { Example } from './example.stories'; -import { ImportPath } from '../../__docs__/ImportPath'; -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; +import { Example } from './example.stories' +import { ImportPath } from '../../__docs__/ImportPath' +import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs' @@ -13,16 +13,13 @@ Manages a single LocalStorage key. - Synchronized between all hooks on the page with the same key. - SSR compatible. -> **_This hook provides stable API, meaning returned methods does not change between renders_** - -> This hook uses `useSafeState` underneath, so it is safe to use its `setState` in async hooks. +> **_This hook provides stable API, meaning the returned methods do not change between renders._** > Does not allow usage of `null` value, since JSON allows serializing `null` values - it would be > impossible to separate null value fom 'no such value' API result which is also `null`. -> While using SSR, to avoid hydration mismatch, consider setting `initializeWithStorageValue` option -> to `false`, this will yield `undefined` state on first render and defer value fetch till effects -> execution stage. +> Due to support of SSR this hook returns undefined on first render even if value is there, to avoid +> this behavior set the `initializeWithValue` option to true. #### Example @@ -50,23 +47,22 @@ function useLocalStorageValue( + #### Arguments - **key** _`string`_ - LocalStorage key to manage. -- **defaultValue** _`T | null`_ _(default: null)_ - Default value to return in case key not - presented in LocalStorage. - **options** _`object`_ - Hook options: - - **isolated** _`boolean`_ _(default: false)_ - Disable synchronisation with other hook instances - with the same key on the same page. - - **handleStorageEvent** _`boolean`_ _(default: true)_ - Subscribe to window's `storage` event. - - **storeDefaultValue** _`boolean`_ _(default: false)_ - store default value. - - **initializeWithStorageValue** _`boolean`_ _(default: true)_ - fetch storage value on first - render. If set to `false` will make hook to yield `undefined` state on first render and defer - value fetch till effects execution stage. + - **defaultValue** _`T | null`_ - Value to return if `key` is not present in LocalStorage. + - **initializeWithValue** _`boolean`_ _(default: true)_ - Fetch storage value on first render. If + set to `false` will make the hook yield `undefined` on first render and defer fetching of the + value until effects are executed. #### Return -0. **state** - LocalStorage item value or default value in case of item absence. -1. **setValue** - Method to set new item value. -2. **removeValue** - Method to remove item from storage. -3. **fetchValue** - Method to pull value from localStorage. +Object with following properties. Note that this object changes with value while its methods are +stable between renders, thus it is safe to pass them as props. +- **value** - LocalStorage value of the given `key` argument or `defaultValue`, if the key was not +present. +- **set** - Method to set a new value for the managed `key`. +- **remove** - Method to remove the current value of `key`. +- **fetch** - Method to manually retrieve the value of `key`. diff --git a/src/useLocalStorageValue/useLocalStorageValue.ts b/src/useLocalStorageValue/useLocalStorageValue.ts index fa39c664..fdfe61af 100644 --- a/src/useLocalStorageValue/useLocalStorageValue.ts +++ b/src/useLocalStorageValue/useLocalStorageValue.ts @@ -1,11 +1,11 @@ import { - HookReturn, - UseStorageValueOptions, useStorageValue, + UseStorageValueOptions, + UseStorageValueResult, } from '../useStorageValue/useStorageValue'; import { isBrowser, noop } from '../util/const'; -let IS_LOCAL_STORAGE_AVAILABLE = false; +let IS_LOCAL_STORAGE_AVAILABLE: boolean; try { IS_LOCAL_STORAGE_AVAILABLE = isBrowser && !!window.localStorage; @@ -15,62 +15,34 @@ try { IS_LOCAL_STORAGE_AVAILABLE = false; } -interface UseLocalStorageValue { - (key: string, defaultValue?: null, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - UseStorageValueOptions - >; - - ( - key: string, - defaultValue: null, - options: UseStorageValueOptions - ): HookReturn; - - (key: string, defaultValue: T, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - UseStorageValueOptions - >; - - (key: string, defaultValue: T, options: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - typeof options - >; - - (key: string, defaultValue?: T | null, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - typeof options - >; -} +type UseLocalStorageValue = < + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined +>( + key: string, + options?: UseStorageValueOptions +) => UseStorageValueResult; /** * Manages a single localStorage key. - * - * @param key Storage key to manage - * @param defaultValue Default value to yield in case the key is not in storage - * @param options */ -export const useLocalStorageValue: UseLocalStorageValue = IS_LOCAL_STORAGE_AVAILABLE - ? ( - key: string, - defaultValue: T | null = null, - options: UseStorageValueOptions = {} - ): HookReturn => - useStorageValue(localStorage, key, defaultValue, options) - : ( - key: string, - defaultValue: T | null = null, - options: UseStorageValueOptions = {} - ): HookReturn => { - /* istanbul ignore next */ +export const useLocalStorageValue: UseLocalStorageValue = !IS_LOCAL_STORAGE_AVAILABLE + ? < + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined + >( + _key: string, + _options?: UseStorageValueOptions + ): UseStorageValueResult => { if (isBrowser && process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn('LocalStorage is not available in this environment'); } - return [undefined, noop, noop, noop]; + return { value: undefined as Type, set: noop, remove: noop, fetch: noop }; + } + : (key, options) => { + return useStorageValue(localStorage, key, options); }; diff --git a/src/useSessionStorageValue/__docs__/example.stories.tsx b/src/useSessionStorageValue/__docs__/example.stories.tsx index af98853d..7fe77823 100644 --- a/src/useSessionStorageValue/__docs__/example.stories.tsx +++ b/src/useSessionStorageValue/__docs__/example.stories.tsx @@ -10,26 +10,13 @@ interface ExampleProps { * SessionStorage key to manage. */ key: string; - /** - * Subscribe to window's `storage` event. - */ - handleStorageEvent: boolean; - /** - * Isolate hook from others on page - it will not receive updates from other hooks with the same key. - */ - isolated: boolean; } export const Example: React.FC = ({ key = 'react-hookz-ss-test', defaultValue = '@react-hookz is awesome', - handleStorageEvent = true, - isolated = false, }) => { - const [value, setValue, removeValue] = useSessionStorageValue(key, defaultValue, { - handleStorageEvent, - isolated, - }); + const ssVal = useSessionStorageValue(key, { defaultValue, initializeWithValue: true }); return (
@@ -40,12 +27,12 @@ export const Example: React.FC = ({
{ - setValue(ev.currentTarget.value); + ssVal.set(ev.currentTarget.value); }} - />{' '} - + /> +
); }; diff --git a/src/useSessionStorageValue/__docs__/story.mdx b/src/useSessionStorageValue/__docs__/story.mdx index ead56adc..a6475888 100644 --- a/src/useSessionStorageValue/__docs__/story.mdx +++ b/src/useSessionStorageValue/__docs__/story.mdx @@ -1,6 +1,6 @@ -import { Example } from './example.stories'; -import { ImportPath } from '../../__docs__/ImportPath'; -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; +import { Example } from './example.stories' +import { ImportPath } from '../../__docs__/ImportPath' +import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs' @@ -13,16 +13,13 @@ Manages a single SessionStorage key. - Synchronized between all hooks on the page with the same key. - SSR compatible. -> **_This hook provides stable API, meaning returned methods does not change between renders_** - -> This hook uses `useSafeState` underneath, so it is safe to use its `setState` in async hooks. +> **_This hook provides stable API, meaning the returned methods do not change between renders._** > Does not allow usage of `null` value, since JSON allows serializing `null` values - it would be > impossible to separate null value fom 'no such value' API result which is also `null`. -> While using SSR, to avoid hydration mismatch, consider setting `initializeWithStorageValue` option -> to `false`, this will yield `undefined` state on first render and defer value fetch till effects -> execution stage. +> Due to support of SSR this hook returns undefined on first render even if value is there, to avoid +> this behavior set the `initializeWithValue` option to true. #### Example @@ -53,20 +50,18 @@ function useSessionStorageValue( #### Arguments - **key** _`string`_ - SessionStorage key to manage. -- **defaultValue** _`T | null`_ _(default: null)_ - Default value to return in case key not - presented in SessionStorage. - **options** _`object`_ - Hook options: - - **isolated** _`boolean`_ _(default: false)_ - Disable synchronisation with other hook instances - with the same key on same page. - - **handleStorageEvent** _`boolean`_ _(default: true)_ - Subscribe to window's `storage` event. - - **storeDefaultValue** _`boolean`_ _(default: false)_ - store default value. - - **initializeWithStorageValue** _`boolean`_ _(default: true)_ - fetch storage value on first - render. If set to `false` will make hook to yield `undefined` state on first render and defer - value fetch till effects execution stage. + - **defaultValue** _`T | null`_ - Value to return if `key` is not present in SessionStorage. + - **initializeWithValue** _`boolean`_ _(default: true)_ - Fetch storage value on first render. If + set to `false` will make the hook yield `undefined` on first render and defer fetching of the + value until effects are executed. #### Return -0. **state** - SessionStorage item value or default value in case of item absence. -1. **setValue** - Method to set new item value. -2. **removeValue** - Method to remove item from storage. -3. **fetchValue** - Method to pull value from sessionStorage. +Object with following properties. Note that this object changes with value while its methods are +stable between renders, thus it is safe to pass them as props. +- **value** - SessionStorage value of the given `key` argument or `defaultValue`, if the key was not +present. +- **set** - Method to set a new value for the managed `key`. +- **remove** - Method to remove the current value of `key`. +- **fetch** - Method to manually retrieve the value of `key`. diff --git a/src/useSessionStorageValue/useSessionStorageValue.ts b/src/useSessionStorageValue/useSessionStorageValue.ts index 9c2bf4fe..50904811 100644 --- a/src/useSessionStorageValue/useSessionStorageValue.ts +++ b/src/useSessionStorageValue/useSessionStorageValue.ts @@ -1,76 +1,48 @@ import { - HookReturn, - UseStorageValueOptions, useStorageValue, + UseStorageValueOptions, + UseStorageValueResult, } from '../useStorageValue/useStorageValue'; import { isBrowser, noop } from '../util/const'; -let IS_SESSION_STORAGE_AVAILABLE = false; +let IS_SESSION_STORAGE_AVAILABLE: boolean; try { IS_SESSION_STORAGE_AVAILABLE = isBrowser && !!window.sessionStorage; } catch { - // no need to test this flag leads to noop behaviour + // no need to test as this flag leads to noop behaviour /* istanbul ignore next */ IS_SESSION_STORAGE_AVAILABLE = false; } -interface UseSessionStorageValue { - (key: string, defaultValue?: null, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - UseStorageValueOptions - >; - - ( - key: string, - defaultValue: null, - options: UseStorageValueOptions - ): HookReturn; - - (key: string, defaultValue: T, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - UseStorageValueOptions - >; - - (key: string, defaultValue: T, options: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - typeof options - >; - - (key: string, defaultValue?: T | null, options?: UseStorageValueOptions): HookReturn< - T, - typeof defaultValue, - typeof options - >; -} +type UseSessionStorageValue = < + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined +>( + key: string, + options?: UseStorageValueOptions +) => UseStorageValueResult; /** * Manages a single sessionStorage key. - * - * @param key Storage key to manage - * @param defaultValue Default value to yield in case the key is not in storage - * @param options */ -export const useSessionStorageValue: UseSessionStorageValue = IS_SESSION_STORAGE_AVAILABLE - ? ( - key: string, - defaultValue: T | null = null, - options: UseStorageValueOptions = {} - ): HookReturn => - useStorageValue(sessionStorage, key, defaultValue, options) - : ( - key: string, - defaultValue: T | null = null, - options: UseStorageValueOptions = {} - ): HookReturn => { - /* istanbul ignore next */ +export const useSessionStorageValue: UseSessionStorageValue = !IS_SESSION_STORAGE_AVAILABLE + ? < + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined + >( + _key: string, + _options?: UseStorageValueOptions + ): UseStorageValueResult => { if (isBrowser && process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn('SessionStorage is not available in this environment'); } - return [undefined, noop, noop, noop]; + return { value: undefined as Type, set: noop, remove: noop, fetch: noop }; + } + : (key, options) => { + return useStorageValue(sessionStorage, key, options); }; diff --git a/src/useStorageValue/__tests__/dom.ts b/src/useStorageValue/__tests__/dom.ts index 85aab306..939761e3 100644 --- a/src/useStorageValue/__tests__/dom.ts +++ b/src/useStorageValue/__tests__/dom.ts @@ -1,302 +1,204 @@ import { act, renderHook } from '@testing-library/react-hooks/dom'; import { useStorageValue } from '../useStorageValue'; -import Mocked = jest.Mocked; +import { newStorage } from './misc'; describe('useStorageValue', () => { it('should be defined', () => { expect(useStorageValue).toBeDefined(); }); - const adapter = { - getItem: jest.fn(() => null), - - setItem: jest.fn(() => {}), - - removeItem: jest.fn(() => {}), - } as unknown as Mocked; - - beforeEach(() => { - adapter.getItem.mockClear().mockImplementation(() => null); - adapter.setItem.mockClear(); - adapter.removeItem.mockClear(); - }); - it('should render', () => { - const { result } = renderHook(() => useStorageValue(adapter, 'foo')); + const { result } = renderHook(() => useStorageValue(newStorage(), 'foo')); + expect(result.error).toBeUndefined(); }); it('should fetch value from storage only on init', () => { - adapter.getItem.mockImplementationOnce((key) => `"${key}"`); - const { result, rerender } = renderHook(() => useStorageValue(adapter, 'foo')); - expect(result.current[0]).toBe('foo'); - expect(adapter.getItem).toHaveBeenCalledWith('foo'); + const storage = newStorage((key) => `"${key}"`); + const { result, rerender } = renderHook(() => useStorageValue(storage, 'foo')); + + expect(result.current.value).toBe('foo'); + expect(storage.getItem).toHaveBeenCalledWith('foo'); + rerender(); rerender(); rerender(); - expect(adapter.getItem).toHaveBeenCalledTimes(1); + + expect(storage.getItem).toHaveBeenCalledTimes(1); }); it('should pass value through JSON.parse during fetch', () => { const JSONParseSpy = jest.spyOn(JSON, 'parse'); - adapter.getItem.mockImplementationOnce((key) => `"${key}"`); - const { result } = renderHook(() => useStorageValue(adapter, 'foo')); - expect(result.current[0]).toBe('foo'); + const storage = newStorage((key) => `"${key}"`); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); + + expect(result.current.value).toBe('foo'); expect(JSONParseSpy).toHaveBeenCalledWith('"foo"'); + JSONParseSpy.mockRestore(); }); it('should yield default value in case storage returned null during fetch', () => { - adapter.getItem.mockImplementationOnce(() => null); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'defaultValue')); - expect(result.current[0]).toBe('defaultValue'); - expect(adapter.getItem).toHaveBeenCalledWith('foo'); + const { result } = renderHook(() => + useStorageValue(newStorage(), 'foo', { defaultValue: 'defaultValue' }) + ); + + expect(result.current.value).toBe('defaultValue'); }); it('should yield default value and console.warn in case storage returned corrupted JSON', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); - adapter.getItem.mockImplementationOnce(() => 'corrupted JSON'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'defaultValue')); - expect(result.current[0]).toBe('defaultValue'); + const { result } = renderHook(() => + useStorageValue( + newStorage(() => 'corrupted JSON'), + 'foo', + { defaultValue: 'defaultValue' } + ) + ); + + expect(result.current.value).toBe('defaultValue'); expect(warnSpy.mock.calls[0][0]).toBeInstanceOf(SyntaxError); + warnSpy.mockRestore(); }); - it('should not fetch value on first render in case `initializeWithStorageValue` options is set to false', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); + it('should not fetch value on first render in case `initializeWithValue` options is set to false', () => { const { result } = renderHook(() => - useStorageValue(adapter, 'foo', null, { initializeWithStorageValue: false }) + useStorageValue( + newStorage(() => '"bar"'), + 'foo', + { initializeWithValue: false } + ) ); + // @ts-expect-error invalid typings of testing library - expect(result.all[0][0]).toBe(undefined); + expect(result.all[0].value).toBe(undefined); + // @ts-expect-error invalid typings of testing library + expect(result.all[1].value).toBe('bar'); + }); + + it('should fetch value on first render in case `initializeWithValue` options is set to true', () => { + const { result } = renderHook(() => + useStorageValue( + newStorage(() => '"bar"'), + 'foo', + { initializeWithValue: true } + ) + ); // @ts-expect-error invalid typings of testing library - expect(result.all[1][0]).toBe('bar'); + expect(result.all[0].value).toBe('bar'); }); - it('should set storage value on setState call', () => { - adapter.getItem.mockImplementationOnce(() => null); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', null)); + it('should set storage value on .set() call', () => { + const { result } = renderHook(() => useStorageValue(newStorage(), 'foo')); - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); act(() => { - result.current[1]('bar'); + result.current.set('bar'); }); - expect(result.current[0]).toBe('bar'); + expect(result.current.value).toBe('bar'); const spySetter = jest.fn(() => 'baz'); act(() => { - result.current[1](spySetter); + result.current.set(spySetter); }); - expect(result.current[0]).toBe('baz'); + expect(result.current.value).toBe('baz'); expect(spySetter).toHaveBeenCalledWith('bar'); }); it('should call JSON.stringify on setState call', () => { const JSONStringifySpy = jest.spyOn(JSON, 'stringify'); - adapter.getItem.mockImplementationOnce(() => null); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', null)); + const { result } = renderHook(() => useStorageValue(newStorage(), 'foo')); - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); act(() => { - result.current[1]('bar'); + result.current.set('bar'); }); - expect(result.current[0]).toBe('bar'); + expect(result.current.value).toBe('bar'); expect(JSONStringifySpy).toHaveBeenCalledWith('bar'); JSONStringifySpy.mockRestore(); }); - it('should not store null or data that cannot be processed by JSON serializer and emit console.warn', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); - adapter.getItem.mockImplementationOnce(() => '"bar"'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'default value')); + it('should not store null or data that cannot be processed by JSON serializer', () => { + const { result } = renderHook(() => + useStorageValue( + newStorage(() => '"bar"'), + 'foo', + { defaultValue: 'default value' } + ) + ); const invalidData: { a?: unknown } = {}; invalidData.a = { b: invalidData }; - expect(result.current[0]).toBe('bar'); + expect(result.current.value).toBe('bar'); act(() => { // @ts-expect-error testing inappropriate use - result.current[1](null); + result.current.set(null); }); - expect(result.current[0]).toBe('bar'); - expect(warnSpy).toHaveBeenCalledWith( - `'null' is not a valid data for useStorageValue hook, this operation will take no effect` - ); - - warnSpy.mockRestore(); + expect(result.current.value).toBe('bar'); }); - it('should call storage`s removeItem on item remove', () => { - adapter.getItem.mockImplementationOnce(() => null); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', null)); + it('should call storage`s removeItem on .remove() call', () => { + const storage = newStorage(); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); act(() => { - result.current[2](); + result.current.remove(); }); - expect(adapter.removeItem).toHaveBeenCalledWith('foo'); + expect(storage.removeItem).toHaveBeenCalledWith('foo'); }); it('should set state to default value on item remove', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'default value')); - - expect(result.current[0]).toBe('bar'); - act(() => { - result.current[2](); - }); - expect(result.current[0]).toBe('default value'); - }); - - it('should refetch value from store on fetchItem call', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'default value')); - - expect(adapter.getItem).toHaveBeenCalledTimes(1); - expect(result.current[0]).toBe('bar'); - adapter.getItem.mockImplementationOnce(() => '"baz"'); - act(() => { - result.current[3](); - }); - expect(adapter.getItem).toHaveBeenCalledTimes(2); - expect(result.current[0]).toBe('baz'); - }); - - it('should refetch value on key change', () => { - adapter.getItem.mockImplementation((key) => `"${key}"`); - const { result, rerender } = renderHook( - ({ key }) => useStorageValue(adapter, key, 'default value'), - { initialProps: { key: 'foo' } } - ); - - expect(result.current[0]).toBe('foo'); - rerender({ key: 'bar' }); - expect(result.current[0]).toBe('bar'); - }); - - it('should store initially default value to storage if configured', () => { - adapter.getItem.mockImplementationOnce(() => null); const { result } = renderHook(() => - useStorageValue(adapter, 'foo', 'default value', { - storeDefaultValue: true, - }) + useStorageValue( + newStorage(() => '"bar"'), + 'foo', + { defaultValue: 'default value' } + ) ); - expect(result.current[0]).toBe('default value'); - expect(adapter.setItem).toHaveBeenCalledWith('foo', '"default value"'); + expect(result.current.value).toBe('bar'); + act(() => { + result.current.remove(); + }); + expect(result.current.value).toBe('default value'); }); - it('should store default value if it became default after initial render', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); + it('should refetch value from store on .fetch() call', () => { + const storage = newStorage(() => '"bar"'); const { result } = renderHook(() => - useStorageValue(adapter, 'foo', 'default value', { - storeDefaultValue: true, - }) + useStorageValue(storage, 'foo', { defaultValue: 'default value' }) ); - adapter.getItem.mockImplementationOnce(() => null); - expect(result.current[0]).toBe('bar'); - expect(adapter.setItem).not.toHaveBeenCalled(); + expect(storage.getItem).toHaveBeenCalledTimes(1); + expect(result.current.value).toBe('bar'); + storage.getItem.mockImplementationOnce(() => '"baz"'); act(() => { - result.current[2](); + result.current.fetch(); }); - expect(result.current[0]).toBe('default value'); - expect(adapter.setItem).toHaveBeenCalledWith('foo', '"default value"'); - }); - - it('should not store default value on rerenders with persisted state', () => { - adapter.getItem.mockImplementationOnce(() => null); - const { result, rerender } = renderHook(() => - useStorageValue(adapter, 'foo', 'default value', { - storeDefaultValue: true, - }) - ); - - expect(result.current[0]).toBe('default value'); - expect(adapter.setItem).toHaveBeenCalledWith('foo', '"default value"'); - rerender(); - rerender(); - rerender(); - expect(adapter.setItem).toHaveBeenCalledTimes(1); + expect(storage.getItem).toHaveBeenCalledTimes(2); + expect(result.current.value).toBe('baz'); }); - it('should not store null default value to store', () => { - adapter.getItem.mockImplementationOnce(() => null); - renderHook(() => - useStorageValue(adapter, 'foo', null, { - storeDefaultValue: true, - }) + it('should refetch value on key change', () => { + const storage = newStorage((k) => `"${k}"`); + const { result, rerender } = renderHook( + ({ key }) => useStorageValue(storage, key, { defaultValue: 'default value' }), + { initialProps: { key: 'foo' } } ); - expect(adapter.setItem).not.toHaveBeenCalled(); + expect(result.current.value).toBe('foo'); + rerender({ key: 'bar' }); + expect(result.current.value).toBe('bar'); }); describe('should handle window`s `storage` event', () => { - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - beforeEach(() => { - addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); - }); - - it('should subscribe to event on mount', () => { - renderHook(() => useStorageValue(adapter, 'foo')); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const subscribeCall = addEventListenerSpy.mock.calls.find((i) => i[0] === 'storage')!; - expect(subscribeCall).not.toBe(undefined); - expect(subscribeCall[1]).toBeInstanceOf(Function); - expect(subscribeCall[2]).toStrictEqual({ passive: true }); - }); - - it('should not subscribe to event on mount if synchronisations is disabled', () => { - renderHook(() => - useStorageValue(adapter, 'foo', null, { handleStorageEvent: false }) - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const subscribeCall = addEventListenerSpy.mock.calls.find((i) => i[0] === 'storage')!; - expect(subscribeCall).toBe(undefined); - }); - - it('should not resubscribe for event even if key has changed', () => { - const { rerender } = renderHook(({ key }) => useStorageValue(adapter, key), { - initialProps: { key: 'foo' }, - }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const subscribeCall = addEventListenerSpy.mock.calls.find((i) => i[0] === 'storage')!; - const storageEventHandler = subscribeCall[1]; - - addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); - - rerender({ key: 'bar' }); - expect(removeEventListenerSpy.mock.calls.find((i) => i[1] === storageEventHandler)).toBe( - undefined - ); - }); - - it('should unsubscribe on unmount', () => { - const { unmount } = renderHook(() => useStorageValue(adapter, 'foo')); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const subscribeCall = addEventListenerSpy.mock.calls.find((i) => i[0] === 'storage')!; - const storageEventHandler = subscribeCall[1]; - - addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); - - unmount(); - expect(removeEventListenerSpy.mock.calls.find((i) => i[1] === storageEventHandler)).not.toBe( - undefined - ); - }); - - it('should update state if managed key is updated, without calls to storage', () => { + it('should update state if tracked key is updated', () => { const { result } = renderHook(() => useStorageValue(localStorage, 'foo')); - - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); localStorage.setItem('foo', 'bar'); act(() => { @@ -305,14 +207,13 @@ describe('useStorageValue', () => { ); }); - expect(result.current[0]).toBe('foo'); + expect(result.current.value).toBe('foo'); localStorage.removeItem('foo'); }); it('should not update data on event storage or key mismatch', () => { const { result } = renderHook(() => useStorageValue(localStorage, 'foo')); - - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); act(() => { window.dispatchEvent( @@ -323,7 +224,7 @@ describe('useStorageValue', () => { }) ); }); - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); act(() => { window.dispatchEvent( @@ -334,7 +235,7 @@ describe('useStorageValue', () => { }) ); }); - expect(result.current[0]).toBe(null); + expect(result.current.value).toBe(null); localStorage.removeItem('foo'); }); @@ -343,95 +244,29 @@ describe('useStorageValue', () => { describe('synchronisation', () => { it('should update state of all hooks with the same key in same storage', () => { const { result: res } = renderHook(() => useStorageValue(localStorage, 'foo')); - const { result: res1 } = renderHook(() => useStorageValue(localStorage, 'foo')); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); - - act(() => { - res.current[1]('bar'); - }); - expect(res.current[0]).toBe('bar'); - expect(res1.current[0]).toBe('bar'); - - act(() => { - res.current[2](); - }); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); - - localStorage.setItem('foo', '"123"'); - act(() => { - res.current[3](); - }); - expect(res.current[0]).toBe('123'); - expect(res1.current[0]).toBe('123'); - localStorage.removeItem('foo'); - }); - - it('should not synchronize isolated hooks', () => { - const { result: res } = renderHook(() => useStorageValue(localStorage, 'foo')); - - const { result: res1 } = renderHook(() => - useStorageValue(localStorage, 'foo', null, { isolated: true }) - ); - - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); - - act(() => { - res.current[1]('bar'); - }); - expect(res.current[0]).toBe('bar'); - expect(res1.current[0]).toBe(null); - - act(() => { - res.current[2](); - }); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); - - localStorage.setItem('foo', '"123"'); - act(() => { - res.current[3](); - }); - act(() => { - res.current[3](); - }); - expect(res.current[0]).toBe('123'); - expect(res1.current[0]).toBe(null); - localStorage.removeItem('foo'); - }); - - it('should not be synchronized by isolated hooks', () => { - const { result: res } = renderHook(() => useStorageValue(localStorage, 'foo')); - - const { result: res1 } = renderHook(() => - useStorageValue(localStorage, 'foo', null, { isolated: true }) - ); - - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); + expect(res.current.value).toBe(null); + expect(res1.current.value).toBe(null); act(() => { - res1.current[1]('bar'); + res.current.set('bar'); }); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe('bar'); + expect(res.current.value).toBe('bar'); + expect(res1.current.value).toBe('bar'); act(() => { - res1.current[2](); + res.current.remove(); }); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe(null); + expect(res.current.value).toBe(null); + expect(res1.current.value).toBe(null); localStorage.setItem('foo', '"123"'); act(() => { - res1.current[3](); + res.current.fetch(); }); - expect(res.current[0]).toBe(null); - expect(res1.current[0]).toBe('123'); + expect(res.current.value).toBe('123'); + expect(res1.current.value).toBe('123'); localStorage.removeItem('foo'); }); }); diff --git a/src/useStorageValue/__tests__/misc.ts b/src/useStorageValue/__tests__/misc.ts new file mode 100644 index 00000000..69959685 --- /dev/null +++ b/src/useStorageValue/__tests__/misc.ts @@ -0,0 +1,13 @@ +import Mocked = jest.Mocked; + +export const newStorage = ( + get: Storage['getItem'] = () => null, + set: Storage['setItem'] = () => {}, + remove: Storage['removeItem'] = () => {} +) => { + return { + getItem: jest.fn(get), + setItem: jest.fn(set), + removeItem: jest.fn(remove), + } as unknown as Mocked; +}; diff --git a/src/useStorageValue/__tests__/ssr.ts b/src/useStorageValue/__tests__/ssr.ts index 6d5dce8b..39d0b5b5 100644 --- a/src/useStorageValue/__tests__/ssr.ts +++ b/src/useStorageValue/__tests__/ssr.ts @@ -1,100 +1,69 @@ import { act, renderHook } from '@testing-library/react-hooks/server'; import { useStorageValue } from '../useStorageValue'; -import Mocked = jest.Mocked; +import { newStorage } from './misc'; describe('useStorageValue', () => { it('should be defined', () => { expect(useStorageValue).toBeDefined(); }); - const adapter = { - getItem: jest.fn(() => null), - - setItem: jest.fn(() => {}), - - removeItem: jest.fn(() => {}), - } as unknown as Mocked; - - beforeEach(() => { - adapter.getItem.mockClear().mockImplementation(() => null); - adapter.setItem.mockClear(); - adapter.removeItem.mockClear(); - }); - it('should render', () => { - const { result } = renderHook(() => useStorageValue(adapter, 'foo')); + const { result } = renderHook(() => useStorageValue(newStorage(), 'foo')); expect(result.error).toBeUndefined(); }); it('should not fetch value from storage on init', () => { - const { result } = renderHook(() => useStorageValue(adapter, 'foo')); - expect(result.current[0]).toBe(undefined); - expect(adapter.getItem).not.toHaveBeenCalled(); - }); - - it('should not set storage value on setState call', () => { - const { result } = renderHook(() => useStorageValue(adapter, 'foo')); + const storage = newStorage(); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); - expect(result.current[0]).toBe(undefined); - act(() => { - result.current[1]('bar'); - }); - expect(result.current[0]).toBe(undefined); - expect(adapter.setItem).not.toHaveBeenCalled(); + expect(result.current.value).toBe(undefined); + expect(storage.getItem).not.toHaveBeenCalled(); }); - it('should not call storage`s removeItem on item remove', () => { - adapter.getItem.mockImplementationOnce(() => null); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', null)); + it('should not fetch value from storage on .fetch() call', () => { + const storage = newStorage(); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); + expect(result.current.value).toBe(undefined); act(() => { - result.current[2](); + result.current.fetch(); }); - expect(adapter.removeItem).not.toHaveBeenCalled(); + expect(result.current.value).toBe(undefined); + expect(storage.getItem).not.toHaveBeenCalled(); }); - it('should not set state to default value on item remove', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'default value')); + it('should not set storage value on .set() call', () => { + const storage = newStorage(); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); - expect(result.current[0]).toBe(undefined); + expect(result.current.value).toBe(undefined); act(() => { - result.current[2](); + result.current.set('bar'); }); - expect(result.current[0]).toBe(undefined); + expect(result.current.value).toBe(undefined); + expect(storage.setItem).not.toHaveBeenCalled(); }); - it('should not re-fetch value from store on fetchItem call', () => { - adapter.getItem.mockImplementationOnce(() => '"bar"'); - const { result } = renderHook(() => useStorageValue(adapter, 'foo', 'default value')); + it('should not call storage`s removeItem on .remove() call', () => { + const storage = newStorage(); + const { result } = renderHook(() => useStorageValue(storage, 'foo')); - expect(adapter.getItem).not.toHaveBeenCalled(); act(() => { - result.current[3](); + result.current.remove(); }); - expect(adapter.getItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); }); - it('should not store initially default value to storage if configured', () => { - adapter.getItem.mockImplementationOnce(() => null); + it('should not set state to default value on item remove', () => { + const storage = newStorage(() => '"bar"'); const { result } = renderHook(() => - useStorageValue(adapter, 'foo', 'default value', { - storeDefaultValue: true, - }) - ); - - expect(result.current[0]).toBe(undefined); - expect(adapter.setItem).not.toHaveBeenCalled(); - }); - - it('should not store null default value to store', () => { - adapter.getItem.mockImplementationOnce(() => null); - renderHook(() => - useStorageValue(adapter, 'foo', null, { - storeDefaultValue: true, - }) + useStorageValue(storage, 'foo', { defaultValue: 'default value' }) ); - expect(adapter.setItem).not.toHaveBeenCalled(); + expect(result.current.value).toBe(undefined); + act(() => { + result.current.remove(); + }); + expect(result.current.value).toBe(undefined); }); }); diff --git a/src/useStorageValue/useStorageValue.ts b/src/useStorageValue/useStorageValue.ts index ba593b75..42d80c8f 100644 --- a/src/useStorageValue/useStorageValue.ts +++ b/src/useStorageValue/useStorageValue.ts @@ -1,336 +1,267 @@ /* eslint-disable @typescript-eslint/no-use-before-define,no-use-before-define */ -import { useCallback } from 'react'; -import { useConditionalEffect } from '../useConditionalEffect/useConditionalEffect'; +import { useEffect, useMemo, useState } from 'react'; import { useFirstMountState } from '../useFirstMountState/useFirstMountState'; import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect/useIsomorphicLayoutEffect'; -import { useMountEffect } from '../useMountEffect/useMountEffect'; -import { usePrevious } from '../usePrevious/usePrevious'; -import { useSafeState } from '../useSafeState/useSafeState'; import { useSyncedRef } from '../useSyncedRef/useSyncedRef'; import { useUpdateEffect } from '../useUpdateEffect/useUpdateEffect'; -import { NextState, resolveHookState } from '../util/resolveHookState'; import { isBrowser } from '../util/const'; import { off, on } from '../util/misc'; +import { NextState, resolveHookState } from '../util/resolveHookState'; -export type UseStorageValueOptions< - InitializeWithValue extends boolean | undefined = boolean | undefined -> = { - /** - * Whether to store default value to store. - * - * @default false - */ - storeDefaultValue?: boolean; +const storageListeners = new Map>>(); + +const invokeStorageKeyListeners = ( + s: Storage, + key: string, + value: string | null, + skipListener?: CallableFunction +) => { + storageListeners + .get(s) + ?.get(key) + ?.forEach((listener) => { + if (listener !== skipListener) { + listener(value); + } + }); +}; + +const storageEventHandler = (evt: StorageEvent) => { + if (evt.storageArea && evt.key && evt.newValue) { + invokeStorageKeyListeners(evt.storageArea, evt.key, evt.newValue); + } +}; + +const addStorageListener = (s: Storage, key: string, listener: CallableFunction) => { + // in case of first listener added within browser environment we + // want to bind single storage event handler + if (isBrowser && storageListeners.size === 0) { + on(window, 'storage', storageEventHandler, { passive: true }); + } + + let keys = storageListeners.get(s); + if (!keys) { + keys = new Map(); + storageListeners.set(s, keys); + } + + let listeners = keys.get(key); + if (!listeners) { + listeners = new Set(); + keys.set(key, listeners); + } + + listeners.add(listener); +}; +const removeStorageListener = (s: Storage, key: string, listener: CallableFunction) => { + const keys = storageListeners.get(s); + /* istanbul ignore next */ + if (!keys) { + return; + } + + const listeners = keys.get(key); + /* istanbul ignore next */ + if (!listeners) { + return; + } + + listeners.delete(listener); + + if (!listeners.size) { + keys.delete(key); + } + + if (!keys.size) { + storageListeners.delete(s); + } + + // unbind storage event handler in browser environment in case there is no + // storage keys listeners left + if (isBrowser && !storageListeners.size) { + off(window, 'storage', storageEventHandler); + } +}; + +export interface UseStorageValueOptions { /** - * Disable synchronisation with other hook instances with the same key on the same page. + * Default value that will be used in absence of value in storage. * - * @default false + * @default undefined */ - isolated?: boolean; + defaultValue?: T; /** - * Subscribe to window's `storage` event. + * Whether to initialize state with storage value or initialize with `undefined` state. * * @default true */ - handleStorageEvent?: boolean; -} & (InitializeWithValue extends undefined - ? { - /** - * Whether to initialize state with storage value or initialize with `undefined` state. - * - * Default to false during SSR - * - * @default true - */ - initializeWithStorageValue?: InitializeWithValue; - } - : { - initializeWithStorageValue: InitializeWithValue; - }); + initializeWithValue?: InitializeWithValue; +} -export type ReturnState< - T, - D, - O, - N = D extends null | undefined ? null | T : T, - U = O extends { initializeWithStorageValue: false } ? undefined | N : N +type UseStorageValueValue< + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined, + N = Default extends null | undefined ? null | Type : Type, + U = Initialize extends false | undefined ? undefined | N : N > = U; -export type HookReturn = [ - ReturnState, - (val: NextState>) => void, - () => void, - () => void -]; +export interface UseStorageValueResult< + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined +> { + value: UseStorageValueValue; -export function useStorageValue( - storage: Storage, - key: string, - defaultValue?: null, - options?: UseStorageValueOptions -): HookReturn>; -export function useStorageValue( - storage: Storage, - key: string, - defaultValue: null, - options: UseStorageValueOptions -): HookReturn; + set: (val: NextState>) => void; + remove: () => void; + fetch: () => void; +} -export function useStorageValue( - storage: Storage, - key: string, - defaultValue: T, - options?: UseStorageValueOptions -): HookReturn>; -export function useStorageValue( - storage: Storage, - key: string, - defaultValue: T, - options: UseStorageValueOptions -): HookReturn; +const DEFAULT_OPTIONS: UseStorageValueOptions = { + defaultValue: null, + initializeWithValue: true, +}; -export function useStorageValue( - storage: Storage, - key: string, - defaultValue?: T | null, - options?: UseStorageValueOptions -): HookReturn; - -/** - * Manages a single storage key. - * - * @param storage Storage instance that will be managed - * @param key Storage key to manage - * @param defaultValue Default value to yield in case the key is not in storage - * @param options - */ -export function useStorageValue( +export function useStorageValue< + Type, + Default extends Type = Type, + Initialize extends boolean | undefined = boolean | undefined +>( storage: Storage, key: string, - defaultValue: T | null = null, - options: UseStorageValueOptions = {} -): HookReturn { - const { isolated } = options; - let { - initializeWithStorageValue = true, - handleStorageEvent = true, - storeDefaultValue = false, - } = options; - - // avoid fetching data from storage during SSR - if (!isBrowser) { - storeDefaultValue = false; - initializeWithStorageValue = false; - handleStorageEvent = false; - } - - // needed to provide stable API - const methods = useSyncedRef({ - fetchVal: () => parse(storage.getItem(key), defaultValue), - storeVal: (val: T) => { + options?: UseStorageValueOptions +): UseStorageValueResult { + const optionsRef = useSyncedRef({ ...DEFAULT_OPTIONS, ...options }); + const storageActions = useSyncedRef({ + fetchRaw: () => storage.getItem(key), + fetch: () => + parse( + storageActions.current.fetchRaw(), + optionsRef.current.defaultValue as Required | null + ), + remove: () => storage.removeItem(key), + store: (val: Type): string | null => { const stringified = stringify(val); - if (stringified) { + if (stringified !== null) { storage.setItem(key, stringified); - return true; } - return false; - }, - removeVal: () => { - storage.removeItem(key); - }, - setVal: (val: string | null) => { - setState(parse(val, defaultValue) as T); - }, - fetchState: () => { - const newVal = methods.current.fetchVal() as T; - setState(newVal); - - return newVal !== stateRef.current ? newVal : null; - }, - setState: (nextState: T | null) => { - setState(nextState === null ? defaultValue : nextState); + return stringified; }, }); const isFirstMount = useFirstMountState(); - const [state, setState] = useSafeState( - initializeWithStorageValue && isFirstMount ? (methods.current.fetchVal() as T) : undefined + const [state, setState] = useState( + options?.initializeWithValue && isFirstMount ? storageActions.current.fetch() : undefined ); - const prevState = usePrevious(state); const stateRef = useSyncedRef(state); - const keyRef = useSyncedRef(key); - const isolatedRef = useSyncedRef(isolated); - - // fetch value on mount for the case `initializeWithStorageValue` is false, - // effects are not invoked during SSR, so there is no need to check isBrowser here - useMountEffect(() => { - if (!initializeWithStorageValue) { - methods.current.fetchState(); - } - }); - // store default value if it is not null and options configured to store default value - useConditionalEffect( - () => { - methods.current.storeVal(defaultValue as T); + const stateActions = useSyncedRef({ + fetch: () => setState(storageActions.current.fetch()), + setRawVal: (val: string | null) => { + setState(parse(val, optionsRef.current.defaultValue)); }, - undefined, - [prevState !== state, storeDefaultValue && state === defaultValue && defaultValue !== null] - ); + }); - // refetch value when key changed useUpdateEffect(() => { - methods.current.fetchState(); + stateActions.current.fetch(); }, [key]); - // subscribe hook for storage events - useIsomorphicLayoutEffect(() => { - if (!handleStorageEvent) return; - - // eslint-disable-next-line unicorn/consistent-function-scoping - const storageHandler = (ev: StorageEvent) => { - if (ev.storageArea !== storage) return; - if (ev.key !== keyRef.current) return; - - methods.current.setVal(ev.newValue); - }; - - on(window, 'storage', storageHandler, { passive: true }); - - return () => { - off(window, 'storage', storageHandler); - }; + useEffect(() => { + if (!options?.initializeWithValue) { + stateActions.current.fetch(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleStorageEvent]); + }, []); - // register hook for same-page synchronisation useIsomorphicLayoutEffect(() => { - if (isolated) return; + const handler = stateActions.current.setRawVal; - let storageKeys = storageKeysUsed.get(storage); - - if (!storageKeys) { - storageKeys = new Map(); - storageKeysUsed.set(storage, storageKeys); - } - - let keySetters = storageKeys.get(key); - - if (!keySetters) { - keySetters = new Set(); - storageKeys.set(key, keySetters); - } - - const mSetState = methods.current.setState; - keySetters.add(mSetState); + addStorageListener(storage, key, handler); return () => { - keySetters?.delete(mSetState); + removeStorageListener(storage, key, handler); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isolated, key]); - - return [ - state, - useCallback( - (newState) => { - if (!isBrowser) return; - - const s = resolveHookState(newState, stateRef.current); - - if (methods.current.storeVal(s)) { - methods.current.setState(s); - - if (!isolatedRef.current) { - // update all other hooks state - storageKeysUsed - .get(storage) - ?.get(keyRef.current) - ?.forEach((setter) => { - if (setter === methods.current.setState) return; - - setter(s); - }); - } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ), - useCallback(() => { - if (!isBrowser) return; + }, [storage, key]); - methods.current.removeVal(); - methods.current.setState(null); + const actions = useSyncedRef({ + set: (val: NextState>) => { + if (!isBrowser) return; - if (!isolatedRef.current) { - // update all other hooks state - storageKeysUsed - .get(storage) - ?.get(keyRef.current) - ?.forEach((setter) => { - if (setter === methods.current.setState) return; + const s = resolveHookState( + val, + stateRef.current as UseStorageValueValue + ); - setter(null); - }); + const storeVal = storageActions.current.store(s); + if (storeVal !== null) { + invokeStorageKeyListeners(storage, key, storeVal); } + }, + delete: () => { + if (!isBrowser) return; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []), - useCallback(() => { + storageActions.current.remove(); + invokeStorageKeyListeners(storage, key, null); + }, + fetch: () => { if (!isBrowser) return; - const newVal = methods.current.fetchState(); - if (newVal !== null && !isolatedRef.current) { - // update all other hooks state - storageKeysUsed - .get(storage) - ?.get(keyRef.current) - ?.forEach((setter) => { - if (setter === methods.current.setState) return; - - setter(newVal); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []), - ]; -} + invokeStorageKeyListeners(storage, key, storageActions.current.fetchRaw()); + }, + }); -const storageKeysUsed = new Map>>(); + return useMemo( + () => ({ + value: state as UseStorageValueValue, + set: actions.current.set, + remove: actions.current.delete, + fetch: actions.current.fetch, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [state] + ); +} const stringify = (data: unknown): string | null => { if (data === null) { - // eslint-disable-next-line no-console - console.warn( - `'null' is not a valid data for useStorageValue hook, this operation will take no effect` - ); + /* istanbul ignore next */ + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + `'null' is not a valid data for useStorageValue hook, this operation will take no effect` + ); + } + return null; } try { return JSON.stringify(data); } catch (error) /* istanbul ignore next */ { - // i have absolutely no idea how to cover this, since modern JSON.stringify does not throw on + // I have absolutely no idea how to cover this, since modern JSON.stringify does not throw on // cyclic references anymore // eslint-disable-next-line no-console console.warn(error); + return null; } }; -const parse = (str: string | null, fallback: unknown): unknown => { +const parse = (str: string | null, fallback: T | null): T | null => { if (str === null) return fallback; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(str); } catch (error) { // eslint-disable-next-line no-console console.warn(error); + return fallback; } };