Skip to content

Commit 7e6087f

Browse files
authored
Merge pull request #2906 from Arktomson/fix/2856-localStorage-ssr-hydration
fix(useLocalStorageState): Add a new SSR secure initialization option.
2 parents a773b76 + c47fc29 commit 7e6087f

4 files changed

Lines changed: 71 additions & 4 deletions

File tree

packages/hooks/src/createUseStorageState/__tests__/index.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { act, renderHook } from '@testing-library/react';
2-
import { describe, expect, test } from 'vitest';
2+
import { createElement } from 'react';
3+
import { renderToString } from 'react-dom/server';
4+
import { describe, expect, test, vi } from 'vitest';
35
import type { Options } from '../index';
46
import { createUseStorageState } from '../index';
57

@@ -98,4 +100,45 @@ describe('useStorageState', () => {
98100
act(() => hook.result.current.setState(undefined));
99101
expect(hook.result.current.state).toBeUndefined();
100102
});
103+
104+
test('should not read storage in SSR when getInitialValueInEffect is true', () => {
105+
const storage = new TestStorage();
106+
storage.setItem('key', JSON.stringify('stored-value'));
107+
const getItemSpy = vi.spyOn(storage, 'getItem');
108+
const useStorageState = createUseStorageState(() => storage);
109+
110+
const Demo = () => {
111+
const [state] = useStorageState('key', {
112+
defaultValue: 'default-value',
113+
getInitialValueInEffect: true,
114+
});
115+
116+
return createElement('span', null, state);
117+
};
118+
119+
const html = renderToString(createElement(Demo));
120+
121+
expect(html).toContain('default-value');
122+
expect(getItemSpy).not.toHaveBeenCalled();
123+
});
124+
125+
test('should read storage in SSR when getInitialValueInEffect is false', () => {
126+
const storage = new TestStorage();
127+
storage.setItem('key', JSON.stringify('stored-value'));
128+
const getItemSpy = vi.spyOn(storage, 'getItem');
129+
const useStorageState = createUseStorageState(() => storage);
130+
131+
const Demo = () => {
132+
const [state] = useStorageState('key', {
133+
defaultValue: 'default-value',
134+
});
135+
136+
return createElement('span', null, state);
137+
};
138+
139+
const html = renderToString(createElement(Demo));
140+
141+
expect(html).toContain('stored-value');
142+
expect(getItemSpy).toHaveBeenCalledWith('key');
143+
});
101144
});

packages/hooks/src/createUseStorageState/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRef, useState } from 'react';
22
import useEventListener from '../useEventListener';
33
import useMemoizedFn from '../useMemoizedFn';
4+
import useMount from '../useMount';
45
import useUpdateEffect from '../useUpdateEffect';
56
import { isFunction, isUndef } from '../utils';
67

@@ -10,6 +11,7 @@ export type SetState<S> = S | ((prevState?: S) => S);
1011

1112
export interface Options<T> {
1213
defaultValue?: T | (() => T);
14+
getInitialValueInEffect?: boolean;
1315
listenStorageChange?: boolean;
1416
serializer?: (value: T) => string;
1517
deserializer?: (value: string) => T;
@@ -19,8 +21,7 @@ export interface Options<T> {
1921
export const createUseStorageState = (getStorage: () => Storage | undefined) => {
2022
const useStorageState = <T>(key: string, options: Options<T> = {}) => {
2123
let storage: Storage | undefined;
22-
23-
const { listenStorageChange = false } = options;
24+
const { listenStorageChange = false, getInitialValueInEffect = false } = options;
2425

2526
const serializer = isFunction(options.serializer) ? options.serializer : JSON.stringify;
2627

@@ -44,13 +45,32 @@ export const createUseStorageState = (getStorage: () => Storage | undefined) =>
4445
} catch (e) {
4546
onError(e);
4647
}
48+
return getDefaultValue();
49+
}
50+
51+
function getDefaultValue() {
4752
if (isFunction(options.defaultValue)) {
4853
return options.defaultValue();
4954
}
55+
5056
return options.defaultValue;
5157
};
5258

53-
const [state, setState] = useState<T>(getStoredValue);
59+
const [state, setState] = useState<T>(() => {
60+
if (getInitialValueInEffect) {
61+
return getDefaultValue();
62+
}
63+
64+
return getStoredValue();
65+
});
66+
67+
useMount(() => {
68+
if (!getInitialValueInEffect) {
69+
return;
70+
}
71+
72+
setState(getStoredValue());
73+
});
5474

5575
const stateRef = useRef<T>(state);
5676
stateRef.current = state;

packages/hooks/src/useLocalStorageState/index.en-US.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type SetState<S> = S | ((prevState?: S) => S);
3434

3535
interface Options<T> {
3636
defaultValue?: T | (() => T);
37+
getInitialValueInEffect?: boolean;
3738
listenStorageChange?: boolean;
3839
serializer?: (value: T) => string;
3940
deserializer?: (value: string) => T;
@@ -58,6 +59,7 @@ const [state, setState] = useLocalStorageState<T>(
5859
| Property | Description | Type | Default |
5960
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
6061
| defaultValue | Default value | `any \| (() => any)` | - |
62+
| getInitialValueInEffect | Whether to read localStorage in an effect after mount, useful to avoid SSR hydration mismatch | `boolean` | `false` |
6163
| listenStorageChange | Whether to listen storage changes. If `true`, when the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser | `boolean` | `false` |
6264
| serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` |
6365
| deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` |

packages/hooks/src/useLocalStorageState/index.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type SetState<S> = S | ((prevState?: S) => S);
3434

3535
interface Options<T> {
3636
defaultValue?: T | (() => T);
37+
getInitialValueInEffect?: boolean;
3738
listenStorageChange?: boolean;
3839
serializer?: (value: T) => string;
3940
deserializer?: (value: string) => T;
@@ -58,6 +59,7 @@ const [state, setState] = useLocalStorageState<T>(
5859
| 参数 | 说明 | 类型 | 默认值 |
5960
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
6061
| defaultValue | 默认值 | `any \| (() => any)` | - |
62+
| getInitialValueInEffect | 是否在挂载后通过 effect 读取 localStorage,可用于避免 SSR hydration 不一致 | `boolean` | `false` |
6163
| listenStorageChange | 是否监听存储变化。如果是 `true`,当存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间 | `boolean` | `false` |
6264
| serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` |
6365
| deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` |

0 commit comments

Comments
 (0)