Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/useStorageValue/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -124,6 +124,10 @@ export type UseStorageValueResult<
> = {
value: UseStorageValueValue<Type, Default, Initialize>;

/**
* 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<Type, UseStorageValueValue<Type, Default, Initialize>>) => void;
remove: () => void;
fetch: () => void;
Expand Down Expand Up @@ -176,14 +180,18 @@ export function useStorageValue<
const [state, setState] = useState<Type | null | undefined>(
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));
},
});

Expand Down
123 changes: 123 additions & 0 deletions src/useStorageValue/sequence.dom.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>(storage, 'counter', {defaultValue: 0}));
const {set} = expectResultValue(result);
const previousValues: Array<number | null | undefined> = [];
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<number>(storage, 'counter', {defaultValue: 0}));
const second = await renderHook(() => useStorageValue<number>(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<number>(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<number>(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<number>(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<number>(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<number>(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');
});
});