diff --git a/src/useStorageValue/index.ts b/src/useStorageValue/index.ts index 65d7fad6..0d85f667 100644 --- a/src/useStorageValue/index.ts +++ b/src/useStorageValue/index.ts @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import {useFirstMountState} from '../useFirstMountState/index.js'; import {useIsomorphicLayoutEffect} from '../useIsomorphicLayoutEffect/index.js'; import {useSyncedRef} from '../useSyncedRef/index.js'; @@ -124,6 +124,10 @@ export type UseStorageValueResult< > = { value: UseStorageValueValue; + /** + * Stores a value. Functional updates receive the latest accepted value, including + * updates from other hooks sharing this storage key before React renders. + */ set: (value: NextState>) => void; remove: () => void; fetch: () => void; @@ -176,14 +180,18 @@ export function useStorageValue< const [state, setState] = useState( optionsRef.current?.initializeWithValue === true && isFirstMount ? storageActions.current.fetch() : undefined, ); - const stateRef = useSyncedRef(state); + const stateRef = useRef(state); + const updateState = (value: Type | null | undefined): void => { + stateRef.current = value; + setState(value); + }; const stateActions = useSyncedRef({ fetch() { - setState(storageActions.current.fetch()); + updateState(storageActions.current.fetch()); }, setRawVal(this: void, value: string | null) { - setState(parse(value, optionsRef.current.defaultValue)); + updateState(parse(value, optionsRef.current.defaultValue)); }, }); diff --git a/src/useStorageValue/sequence.dom.test.ts b/src/useStorageValue/sequence.dom.test.ts new file mode 100644 index 00000000..af1d1b7a --- /dev/null +++ b/src/useStorageValue/sequence.dom.test.ts @@ -0,0 +1,123 @@ +import {act, renderHook} from '@ver0/react-hooks-testing'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {useStorageValue} from './index.js'; +import {expectResultValue} from '../util/testing/test-helpers.js'; + +describe.each(['localStorage', 'sessionStorage'] as const)('useStorageValue sequences with %s', (storageName) => { + beforeEach(() => { + globalThis[storageName].clear(); + }); + + it('composes consecutive functional updates before a render', async () => { + const storage = globalThis[storageName]; + const {result} = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 0})); + const {set} = expectResultValue(result); + const previousValues: Array = []; + const increment = (previous: number | null | undefined): number => { + previousValues.push(previous); + return (previous ?? 0) + 1; + }; + await act(async () => { + set(increment); + set(increment); + set(increment); + }); + expect(previousValues).toEqual([0, 1, 2]); + expect(expectResultValue(result).value).toBe(3); + expect(storage.getItem('counter')).toBe('3'); + }); + + it('uses updates from another hook before either hook renders', async () => { + const storage = globalThis[storageName]; + const first = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 0})); + const second = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 0})); + await act(async () => { + expectResultValue(first.result).set(5); + expectResultValue(second.result).set((previous) => (previous ?? 0) + 1); + expectResultValue(first.result).set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(first.result).value).toBe(7); + expect(expectResultValue(second.result).value).toBe(7); + expect(storage.getItem('counter')).toBe('7'); + }); + + it('uses the fallback after removal in the same event', async () => { + const storage = globalThis[storageName]; + storage.setItem('counter', '10'); + const {result} = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 2})); + const actions = expectResultValue(result); + await act(async () => { + actions.remove(); + actions.set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(result).value).toBe(3); + }); + + it('uses an explicitly fetched value before the next render', async () => { + const storage = globalThis[storageName]; + const {result} = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 0})); + const actions = expectResultValue(result); + await act(async () => { + storage.setItem('counter', '8'); + actions.fetch(); + actions.set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(result).value).toBe(9); + }); + + it('keeps the last accepted value when serialization rejects an update', async () => { + const storage = globalThis[storageName]; + const {result} = await renderHook(() => + useStorageValue(storage, 'counter', { + defaultValue: 0, + stringify: (value) => (value === 100 ? null : String(value)), + }), + ); + const {set} = expectResultValue(result); + await act(async () => { + set(5); + set(100); + set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(result).value).toBe(6); + expect(storage.getItem('counter')).toBe('6'); + }); + it('uses the parsed value rather than the unprocessed setter argument', async () => { + const storage = globalThis[storageName]; + const {result} = await renderHook(() => + useStorageValue(storage, 'counter', { + defaultValue: 0, + parse: (raw, fallback) => (raw === null ? fallback : Math.floor(Number(raw))), + }), + ); + const {set} = expectResultValue(result); + await act(async () => { + set(2.7); + set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(result).value).toBe(3); + expect(storage.getItem('counter')).toBe('3'); + }); + + it('does not advance the current value after a failed write', async () => { + const storage = globalThis[storageName]; + const {result} = await renderHook(() => useStorageValue(storage, 'counter', {defaultValue: 0})); + const {set} = expectResultValue(result); + await act(async () => { + set(5); + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new Error('Storage write failed'); + }); + try { + expect(() => { + set(100); + }).toThrow('Storage write failed'); + } finally { + write.mockRestore(); + } + set((previous) => (previous ?? 0) + 1); + }); + expect(expectResultValue(result).value).toBe(6); + expect(storage.getItem('counter')).toBe('6'); + }); +});