Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(useStoage): listen to storage event #2298

Merged
merged 7 commits into from
Apr 21, 2024
Merged
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
60 changes: 53 additions & 7 deletions packages/hooks/src/createUseStorageState/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useState } from 'react';
import useEventListener from '../useEventListener';
import useMemoizedFn from '../useMemoizedFn';
import useUpdateEffect from '../useUpdateEffect';
import { isFunction, isUndef } from '../utils';

export const SYNC_STORAGE_EVENT_NAME = 'AHOOKS_SYNC_STORAGE_EVENT_NAME';

export type SetState<S> = S | ((prevState?: S) => S);

export interface Options<T> {
defaultValue?: T | (() => T);
listenStorageChange?: boolean;
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
onError?: (error: unknown) => void;
Expand All @@ -16,6 +20,7 @@ export function createUseStorageState(getStorage: () => Storage | undefined) {
function useStorageState<T>(key: string, options: Options<T> = {}) {
let storage: Storage | undefined;
const {
listenStorageChange = false,
onError = (e) => {
console.error(e);
},
Expand Down Expand Up @@ -67,17 +72,58 @@ export function createUseStorageState(getStorage: () => Storage | undefined) {
const currentState = isFunction(value) ? value(state) : value;
setState(currentState);

if (isUndef(currentState)) {
storage?.removeItem(key);
} else {
try {
storage?.setItem(key, serializer(currentState));
} catch (e) {
console.error(e);
try {
let newValue: string | null;
const oldValue = storage?.getItem(key);

if (isUndef(currentState)) {
newValue = null;
storage?.removeItem(key);
} else {
newValue = serializer(currentState);
storage?.setItem(key, newValue);
}

dispatchEvent(
// send custom event to communicate within same page
// importantly this should not be a StorageEvent since those cannot
// be constructed with a non-built-in storage area
new CustomEvent(SYNC_STORAGE_EVENT_NAME, {
detail: {
key,
newValue,
oldValue,
storageArea: storage,
},
}),
);
} catch (e) {
onError(e);
}
};

const syncState = (event: StorageEvent) => {
if (event.key !== key || event.storageArea !== storage) {
return;
}

setState(getStoredValue());
};

const syncStateFromCustomEvent = (event: CustomEvent<StorageEvent>) => {
syncState(event.detail);
};

// from another document
useEventListener('storage', syncState, {
enable: listenStorageChange,
});

// from the same document but different hooks
useEventListener(SYNC_STORAGE_EVENT_NAME, syncStateFromCustomEvent, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncStateFromCustomEvent will be triggered by its own updateState function, causing the setState method to be called twice.

image

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because its own listener also detect its own trigger. whether can we use const hookId = useRef(Symbol(xx)) as current hook's identify. when calling dispatchEvent , pass the hookId. In syncStateFromCustomEvent, compare hookId.
sorry, English is poor.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bowencool Thank you, I will check it again and fix.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that these codes can help:

  const updateState = (value?: SetState<T>) => {
      const currentState = isFunction(value) ? value(state) : value;
+    if (!listenStorageChange) {
        setState(currentState);
+   }
  ...

enable: listenStorageChange,
});

return [state, useMemoizedFn(updateState)] as const;
}
return useStorageState;
Expand Down
20 changes: 20 additions & 0 deletions packages/hooks/src/useEventListener/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,24 @@ describe('useEventListener', () => {
document.body.click();
expect(state).toBe(1);
});

it('test "enable" parameter', () => {
let state = 0;
let enable = true;
const onClick = () => state++;
const { rerender, unmount } = renderHook(() =>
useEventListener('click', onClick, { target: () => container, enable }),
);

document.body.click();
expect(state).toBe(0);
container.click();
expect(state).toBe(1);

enable = false;
rerender();
container.click();
expect(state).toBe(1);
unmount();
});
});
1 change: 1 addition & 0 deletions packages/hooks/src/useEventListener/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ useEventListener(
| capture | Optional, a Boolean indicating that events of this type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. | `boolean` | `false` |
| once | Optional, A Boolean indicating that the listener should be invoked at most once after being added. If true, the listener would be automatically removed when invoked. | `boolean` | `false` |
| passive | Optional, A Boolean which, if true, indicates that the function specified by listener will never call preventDefault(). If a passive listener does call preventDefault(), the user agent will do nothing other than generate a console warning. | `boolean` | `false` |
| enable | Optional, Whether to enable listening. | `boolean` | `true` |
14 changes: 13 additions & 1 deletion packages/hooks/src/useEventListener/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Options<T extends Target = Target> = {
capture?: boolean;
once?: boolean;
passive?: boolean;
enable?: boolean;
};

function useEventListener<K extends keyof HTMLElementEventMap>(
Expand All @@ -34,13 +35,24 @@ function useEventListener<K extends keyof WindowEventMap>(
handler: (ev: WindowEventMap[K]) => void,
options?: Options<Window>,
): void;
function useEventListener(
eventName: string,
handler: (event: Event) => void,
options?: Options<Window>,
): void;
function useEventListener(eventName: string, handler: noop, options: Options): void;

function useEventListener(eventName: string, handler: noop, options: Options = {}) {
const { enable = true } = options;

const handlerRef = useLatest(handler);

useEffectWithTarget(
() => {
if (!enable) {
return;
}

const targetElement = getTargetElement(options.target, window);
if (!targetElement?.addEventListener) {
return;
Expand All @@ -62,7 +74,7 @@ function useEventListener(eventName: string, handler: noop, options: Options = {
});
};
},
[eventName, options.capture, options.once, options.passive],
[eventName, options.capture, options.once, options.passive, enable],
options.target,
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/hooks/src/useEventListener/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ useEventListener(
| capture | 可选项,listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。 | `boolean` | `false` |
| once | 可选项,listener 在添加之后最多只调用一次。如果是 true,listener 会在其被调用之后自动移除。 | `boolean` | `false` |
| passive | 可选项,设置为 true 时,表示 listener 永远不会调用 preventDefault() 。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。 | `boolean` | `false` |
| enable | 可选项,是否开启监听。 | `boolean` | `true` |
25 changes: 23 additions & 2 deletions packages/hooks/src/useLocalStorageState/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { renderHook, act } from '@testing-library/react';
import type { Options } from '../../createUseStorageState';
import useLocalStorageState from '../index';

describe('useLocalStorageState', () => {
const setUp = <T>(key: string, value: T) =>
const setUp = <T>(key: string, value: T, options?: Options<T>) =>
renderHook(() => {
const [state, setState] = useLocalStorageState<T>(key, { defaultValue: value });
const [state, setState] = useLocalStorageState<T>(key, {
defaultValue: value,
...options,
});
return {
state,
setState,
Expand Down Expand Up @@ -106,4 +110,21 @@ describe('useLocalStorageState', () => {
});
expect(hook.result.current.state).toBe('hello world, zhangsan');
});

it('should sync state when changes', async () => {
const LOCAL_STORAGE_KEY = 'test-sync-state';
const hook = setUp(LOCAL_STORAGE_KEY, 'foo', { listenStorageChange: true });
const anotherHook = setUp(LOCAL_STORAGE_KEY, 'bar', { listenStorageChange: true });

expect(hook.result.current.state).toBe('foo');
expect(anotherHook.result.current.state).toBe('bar');

act(() => hook.result.current.setState('baz'));
expect(hook.result.current.state).toBe('baz');
expect(anotherHook.result.current.state).toBe('baz');

act(() => anotherHook.result.current.setState('qux'));
expect(hook.result.current.state).toBe('qux');
expect(anotherHook.result.current.state).toBe('qux');
});
});
38 changes: 38 additions & 0 deletions packages/hooks/src/useLocalStorageState/demo/demo4.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* title: Sync state with localStorage
* desc: When the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser (try to open two tabs of this page, clicking a button on one page will automatically update the "count" on the other page).
*
* title.zh-CN: 将 state 与 localStorage 保持同步
* desc.zh-CN: 存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间(尝试打开两个此页面,点击其中一个页面的按钮,另一个页面的 count 会自动更新)
*/

import React from 'react';
import { useLocalStorageState } from 'ahooks';

export default function () {
return (
<>
<Counter />
<Counter />
</>
);
}

function Counter() {
const [count, setCount] = useLocalStorageState('use-local-storage-state-demo4', {
defaultValue: 0,
listenStorageChange: true,
});

const add = () => setCount(count! + 1);
const clear = () => setCount();

return (
<div style={{ marginBottom: '8px' }}>
<button style={{ marginRight: '8px' }} onClick={add}>
count: {count}
</button>
<button onClick={clear}>Clear</button>
</div>
);
}
17 changes: 11 additions & 6 deletions packages/hooks/src/useLocalStorageState/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ A Hook that store state into localStorage.

<code src="./demo/demo3.tsx" />

### Sync state with localStorage

<code src="./demo/demo4.tsx" />

## API

If you want to delete this record from localStorage, you can use `setState()` or `setState(undefined)`.
Expand Down Expand Up @@ -50,12 +54,13 @@ const [state, setState] = useLocalStorageState<T>(

### Options

| Property | Description | Type | Default |
| ------------ | ----------------------------- | -------------------------- | ----------------------------- |
| defaultValue | Default value | `any \| (() => any)` | - |
| serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` |
| deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` |
| onError | On error callback | `(error: unknown) => void` | `(e) => { console.error(e) }` |
| Property | Description | Type | Default |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
| defaultValue | Default value | `any \| (() => any)` | - |
| 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` |
| serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` |
| deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` |
| onError | On error callback | `(error: unknown) => void` | `(e) => { console.error(e) }` |

## Remark

Expand Down
17 changes: 11 additions & 6 deletions packages/hooks/src/useLocalStorageState/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ nav:

<code src="./demo/demo3.tsx" />

### 将 state 与 localStorage 保持同步

<code src="./demo/demo4.tsx" />

## API

如果想从 localStorage 中删除这条数据,可以使用 `setState()` 或 `setState(undefined)` 。
Expand Down Expand Up @@ -50,12 +54,13 @@ const [state, setState] = useLocalStorageState<T>(

### Options

| 参数 | 说明 | 类型 | 默认值 |
| ------------ | ------------------ | -------------------------- | ----------------------------- |
| defaultValue | 默认值 | `any \| (() => any)` | - |
| serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` |
| deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` |
| onError | 错误回调函数 | `(error: unknown) => void` | `(e) => { console.error(e) }` |
| 参数 | 说明 | 类型 | 默认值 |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
| defaultValue | 默认值 | `any \| (() => any)` | - |
| listenStorageChange | 是否监听存储变化。如果是 `true`,当存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间 | `boolean` | `false` |
| serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` |
| deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` |
| onError | 错误回调函数 | `(error: unknown) => void` | `(e) => { console.error(e) }` |

## 备注

Expand Down