From 2d4a655fdbee0bf3a0479e999eee6932510b15a8 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 8 May 2021 11:09:39 +0300 Subject: [PATCH] feat: useLocalStorageValue same-page synchronisation --- src/useLocalStorageValue.ts | 61 ++++++++++++++++++- .../useLocalStorageValue.story.mdx | 4 ++ tests/dom/useLocalStorageValue.test.ts | 12 ++++ tests/ssr/useLocalStorageValue.test.ts | 12 ++++ tests/ssr/useStorageValue.test.ts | 40 +++++++++++- 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 tests/dom/useLocalStorageValue.test.ts create mode 100644 tests/ssr/useLocalStorageValue.test.ts diff --git a/src/useLocalStorageValue.ts b/src/useLocalStorageValue.ts index 97945ffc..c3f3a690 100644 --- a/src/useLocalStorageValue.ts +++ b/src/useLocalStorageValue.ts @@ -1,7 +1,9 @@ -import { useEffect } from 'react'; +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import { useEffect, useMemo } from 'react'; import { IUseStorageValueOptions, useStorageValue } from './useStorageValue'; import { off, on } from './util/misc'; -import { isBrowser } from './util/const'; +import { isBrowser, noop } from './util/const'; import { INextState } from './util/resolveHookState'; import { useSyncedRef } from './useSyncedRef'; @@ -154,5 +156,58 @@ export function useLocalStorageValue( // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleStorageEvent]); - return [value, setValue, removeValue]; + // keep actual key in hooks registry + useEffect(() => { + if (!usedStorageKeys.has(key)) { + usedStorageKeys.set(key, []); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fetchers = usedStorageKeys.get(key)!; + + fetchers.push(fetchValue); + + return () => { + const idx = fetchers.indexOf(fetchValue); + if (idx !== -1) { + fetchers.splice(idx, 1); + } + + if (!fetchers.length) usedStorageKeys.delete(key); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + // wrapped methods call others hooks `fetchValue` to synchronise state + const wrappedMethods = useMemo( + () => ({ + setValue: ((val) => { + setValue(val); + + usedStorageKeys.get(keyRef.current)?.forEach((fetcher) => { + if (fetcher === fetchValue) return; + + fetcher(); + }); + }) as typeof setValue, + removeValue: (() => { + removeValue(); + + usedStorageKeys.get(keyRef.current)?.forEach((fetcher) => { + if (fetcher === fetchValue) return; + + fetcher(); + }); + }) as typeof removeValue, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // SSR version should literally do nothing to avoid requests to local storage + if (!isBrowser) return [undefined, noop, noop]; + + return [value, wrappedMethods.setValue, wrappedMethods.removeValue]; } + +const usedStorageKeys = new Map void)[]>(); diff --git a/stories/SideEffects/useLocalStorageValue.story.mdx b/stories/SideEffects/useLocalStorageValue.story.mdx index d629473f..6e5ca4da 100644 --- a/stories/SideEffects/useLocalStorageValue.story.mdx +++ b/stories/SideEffects/useLocalStorageValue.story.mdx @@ -17,6 +17,10 @@ execution stage. + + It also synchronised between hooks on same page + + diff --git a/tests/dom/useLocalStorageValue.test.ts b/tests/dom/useLocalStorageValue.test.ts new file mode 100644 index 00000000..6d47d565 --- /dev/null +++ b/tests/dom/useLocalStorageValue.test.ts @@ -0,0 +1,12 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useLocalStorageValue } from '../../src'; + +describe('useLocalStorageValue', () => { + it('should be defined', () => { + expect(useLocalStorageValue).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => useLocalStorageValue('foo')); + }); +}); diff --git a/tests/ssr/useLocalStorageValue.test.ts b/tests/ssr/useLocalStorageValue.test.ts new file mode 100644 index 00000000..89352592 --- /dev/null +++ b/tests/ssr/useLocalStorageValue.test.ts @@ -0,0 +1,12 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useLocalStorageValue } from '../../src'; + +describe('useLocalStorageValue', () => { + it('should be defined', () => { + expect(useLocalStorageValue).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => useLocalStorageValue('foo')); + }); +}); diff --git a/tests/ssr/useStorageValue.test.ts b/tests/ssr/useStorageValue.test.ts index 61683aa6..3b208bd6 100644 --- a/tests/ssr/useStorageValue.test.ts +++ b/tests/ssr/useStorageValue.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks/server'; +import { renderHook, act } from '@testing-library/react-hooks/server'; import { useStorageValue } from '../../src'; describe('useStorageValue', () => { @@ -27,4 +27,42 @@ describe('useStorageValue', () => { expect(result.current[0]).toBe(undefined); expect(adapter.getItem).not.toHaveBeenCalled(); }); + + it('should throw in case non-string value been set in raw mode', () => { + adapter.getItem.mockImplementationOnce(() => null); + const { result } = renderHook(() => + useStorageValue(adapter, 'foo', null, { raw: true }) + ); + + expect(() => { + act(() => { + // @ts-expect-error testing inappropriate usage + result.current[1](123); + }); + }).toThrow( + new TypeError('value has to be a string, define serializer or cast it to string manually') + ); + }); + + it('should call storage`s removeItem on item remove', () => { + adapter.getItem.mockImplementationOnce(() => null); + const { result } = renderHook(() => useStorageValue(adapter, 'foo', null)); + + act(() => { + result.current[2](); + }); + expect(adapter.removeItem).toHaveBeenCalledWith('foo'); + }); + + it('should not store null default value to store', () => { + adapter.getItem.mockImplementationOnce(() => null); + renderHook(() => + useStorageValue(adapter, 'foo', null, { + raw: true, + storeDefaultValue: true, + }) + ); + + expect(adapter.setItem).not.toHaveBeenCalled(); + }); });