Skip to content

Commit

Permalink
feat: useLocalStorageValue same-page synchronisation
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed May 8, 2021
1 parent 2909e46 commit 2d4a655
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 4 deletions.
61 changes: 58 additions & 3 deletions src/useLocalStorageValue.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -154,5 +156,58 @@ export function useLocalStorageValue<T>(
// 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<string, (() => void)[]>();
4 changes: 4 additions & 0 deletions stories/SideEffects/useLocalStorageValue.story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ execution stage.

<Canvas isColumn>
<Story name="Example" story={Example} />

It also synchronised between hooks on same page

<Story name="Example2" story={Example} />
</Canvas>

<ArgsTable story="Example" />
Expand Down
12 changes: 12 additions & 0 deletions tests/dom/useLocalStorageValue.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
12 changes: 12 additions & 0 deletions tests/ssr/useLocalStorageValue.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
40 changes: 39 additions & 1 deletion tests/ssr/useStorageValue.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string>(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<string>(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<string>(adapter, 'foo', null, {
raw: true,
storeDefaultValue: true,
})
);

expect(adapter.setItem).not.toHaveBeenCalled();
});
});

0 comments on commit 2d4a655

Please sign in to comment.