Skip to content

Commit

Permalink
fix(streamich#785): update useStorage to use createGlobalState
Browse files Browse the repository at this point in the history
Fixes streamich#785 - useLocalStorage not updating if many components watch the same key

Previously pr streamich#786 addressed this issue by ensuring that other components
watching the key would see new updates, however, those updates would not be
rendered until something else triggered a re-render. This pr resolves that issue.

This pull request depends on pull requests streamich#1021 streamich#979
  • Loading branch information
Nathan Spaeth committed Jun 20, 2020
1 parent 31a74f0 commit 67acace
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 34 deletions.
66 changes: 48 additions & 18 deletions src/useStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
/* eslint-disable */
import { useState, useCallback, Dispatch, SetStateAction, useMemo } from 'react';
import { useCallback, Dispatch, useMemo } from 'react';
import { InitialHookState, HookState, resolveHookState, } from './util/resolveHookState';
import { isClient } from './util';
import { createGlobalState, GlobalStateHookReturn } from './createGlobalState';

type DispatchAction<T> = Dispatch<HookState<T | undefined>>;
type localStateHook<T> = (initialState?: InitialHookState<T>) => GlobalStateHookReturn<T>;
type storageKeyHooks = {
[storageType: string]: {
[key: string]: localStateHook<any>
}
};
let useStorageKeyHook: storageKeyHooks = {}
// This is useful for testing
export const resetStorageState = () => {
useStorageKeyHook = {}
}

export type parserOptions<T> =
| {
Expand All @@ -19,7 +34,7 @@ const useStorage = <T>(
key: string,
initialValue?: T,
options?: parserOptions<T>
): [T | undefined, Dispatch<SetStateAction<T | undefined>>, () => void] => {
): [T | undefined, DispatchAction<T>, () => void] => {
if (!isClient) {
return [initialValue as T, noop, noop];
}
Expand All @@ -36,7 +51,7 @@ const useStorage = <T>(

const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse;

const [state, setState] = useState<T | undefined>(() => {
const setInitialState = () => {
try {
const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify;

Expand All @@ -47,19 +62,41 @@ const useStorage = <T>(
initialValue && storage.setItem(key, serializer(initialValue));
return initialValue;
}
} catch {
} catch (error) {
// If user is in private mode or has storage restriction
// storage can throw. JSON.parse and JSON.stringify
// can throw, too.
console.error(error)
return initialValue;
}
});
};

if (!useStorageKeyHook[storageType]) {
useStorageKeyHook[storageType] = {}
}
if (!useStorageKeyHook[storageType][key]) {
useStorageKeyHook[storageType][key] = createGlobalState<T | undefined>(undefined)
}
const useStorageState: localStateHook<T | undefined> = useStorageKeyHook[storageType][key]
const [ state, setState ] = useStorageState(setInitialState)

const removeKey = () => {
try {
storage.removeItem(key);
setState(undefined);
} catch(error) {
// If user is in private mode or has storage restriction
// storage can throw.
console.error(error);
}
}

const set: Dispatch<SetStateAction<T | undefined>> = useCallback(
const set: DispatchAction<T> = useCallback(
valOrFunc => {
try {
const newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc;
if (typeof newState === 'undefined') return;
const newState = resolveHookState(valOrFunc, state)
// if (typeof newState === 'undefined') return;
if (typeof newState === 'undefined') return removeKey()
let value: string;

if (options)
Expand All @@ -72,23 +109,16 @@ const useStorage = <T>(

storage.setItem(key, value);
setState(deserializer(value));
} catch {
} catch (error) {
// If user is in private mode or has storage restriction
// storage can throw. Also JSON.stringify can throw.
console.error(error);
}
},
[key, setState]
);

const remove = useCallback(() => {
try {
storage.removeItem(key);
setState(undefined);
} catch {
// If user is in private mode or has storage restriction
// storage can throw.
}
}, [key, setState]);
const remove = useCallback(removeKey, [key, setState]);

return [state, set, remove];
};
Expand Down
29 changes: 18 additions & 11 deletions stories/useLocalStorage.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,28 @@ import * as React from 'react';
import { useLocalStorage } from '../src';
import ShowDocs from './util/ShowDocs';

const StorageKey = ({ storageKey }) => {
const [value, setValue, remove] = useLocalStorage(storageKey);
return (
<div>
<div>(Storage key: { storageKey }) Value: {value}</div>
<button onClick={() => setValue('bar')}>Set to: bar</button>
<button onClick={() => setValue('baz')}>Set to: baz</button>
<button onClick={() => remove()}>Clear</button>
<br />
</div>
);
}

const Demo = () => {
const [value, setValue] = useLocalStorage('hello-key', 'foo');
const [removableValue, setRemovableValue, remove] = useLocalStorage('removeable-key');
useLocalStorage('hello-key', 'initialValue');
useLocalStorage('no-initial-value');

return (
<div>
<div>Value: {value}</div>
<button onClick={() => setValue('bar')}>bar</button>
<button onClick={() => setValue('baz')}>baz</button>
<br />
<br />
<div>Removable Value: {removableValue}</div>
<button onClick={() => setRemovableValue('foo')}>foo</button>
<button onClick={() => setRemovableValue('bar')}>bar</button>
<button onClick={() => remove()}>Remove</button>
<StorageKey storageKey="hello-key" />
<StorageKey storageKey="hello-key" />
<StorageKey storageKey="no-initial-value-key" />
</div>
);
};
Expand Down
23 changes: 18 additions & 5 deletions stories/useSessionStorage.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@ import * as React from 'react';
import { useSessionStorage } from '../src';
import ShowDocs from './util/ShowDocs';

const StorageKey = ({ storageKey }) => {
const [value, setValue, remove] = useSessionStorage(storageKey);
return (
<div>
<div>(Storage key: { storageKey }) Value: {value}</div>
<button onClick={() => setValue('bar')}>Set to: bar</button>
<button onClick={() => setValue('baz')}>Set to: baz</button>
<button onClick={() => remove()}>Clear</button>
<br />
</div>
);
}

const Demo = () => {
const [value, setValue, remove] = useSessionStorage('hello-key', 'foo');
useSessionStorage('hello-key', 'initialValue');
useSessionStorage('no-initial-value');

return (
<div>
<div>Value: {value}</div>
<button onClick={() => setValue('bar')}>bar</button>
<button onClick={() => setValue('baz')}>baz</button>
<button onClick={() => remove()}>Remove</button>
<StorageKey storageKey="hello-key" />
<StorageKey storageKey="hello-key" />
<StorageKey storageKey="no-initial-value-key" />
</div>
);
};
Expand Down
18 changes: 18 additions & 0 deletions tests/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* eslint-disable */
import useLocalStorage from '../src/useLocalStorage';
import { resetStorageState } from '../src/useStorage';
import 'jest-localstorage-mock';
import { renderHook, act } from '@testing-library/react-hooks';

describe(useLocalStorage, () => {
afterEach(() => {
localStorage.clear();
jest.clearAllMocks();
resetStorageState();
});

it('retrieves an existing value from localStorage', () => {
Expand Down Expand Up @@ -234,4 +236,20 @@ describe(useLocalStorage, () => {
expect(JSON.parse(value!).fizz).toEqual('bang');
});
});

it('both components should be updated', () => {
const { result: result1 } = renderHook(() => useLocalStorage('foo', 'bar'));
const { result: result2 } = renderHook(() => useLocalStorage('foo'));
let [ state1, setState1 ] = result1.current;
let [ state2 ] = result2.current;
expect(state1).toBe('bar');
expect(state2).toBe('bar');
act(() => {
setState1('baz');
});
[ state1, setState1 ] = result1.current;
[ state2 ] = result2.current;
expect(state1).toBe('baz');
expect(state2).toBe('baz');
});
});
18 changes: 18 additions & 0 deletions tests/useSessionStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* eslint-disable */
import useSessionStorage from '../src/useSessionStorage';
import { resetStorageState } from '../src/useStorage';
import 'jest-localstorage-mock';
import { renderHook, act } from '@testing-library/react-hooks';

describe(useSessionStorage, () => {
afterEach(() => {
sessionStorage.clear();
jest.clearAllMocks();
resetStorageState();
});

it('retrieves an existing value from sessionStorage', () => {
Expand Down Expand Up @@ -234,4 +236,20 @@ describe(useSessionStorage, () => {
expect(JSON.parse(value!).fizz).toEqual('bang');
});
});

it('both components should be updated', () => {
const { result: result1 } = renderHook(() => useSessionStorage('foo', 'bar'));
const { result: result2 } = renderHook(() => useSessionStorage('foo'));
let [ state1, setState1 ] = result1.current;
let [ state2 ] = result2.current;
expect(state1).toBe('bar');
expect(state2).toBe('bar');
act(() => {
setState1('baz');
});
[ state1, setState1 ] = result1.current;
[ state2 ] = result2.current;
expect(state1).toBe('baz');
expect(state2).toBe('baz');
});
});
36 changes: 36 additions & 0 deletions tests/useStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* eslint-disable */
import useStorage, { resetStorageState } from '../src/useStorage';
import 'jest-localstorage-mock';
import { renderHook, act } from '@testing-library/react-hooks';

describe(useStorage, () => {
afterEach(() => {
sessionStorage.clear();
localStorage.clear();
jest.clearAllMocks();
resetStorageState();
});

it('localStorage keys should not conflict with sessionStorage keys', () => {
const { result: result1 } = renderHook(() => useStorage('localStorage', 'foo', 'baz', { raw: true }));
const { result: result2 } = renderHook(() => useStorage('sessionStorage', 'foo', 'bar', { raw: true }));
let [ state1, setState1 ] = result1.current;
let [ state2, setState2 ] = result2.current;
expect(state1).toBe('baz');
expect(state2).toBe('bar');
act(() => {
setState1('baz-new');
});
[ state1, setState1 ] = result1.current;
[ state2, setState2 ] = result2.current;
expect(state1).toBe('baz-new');
expect(state2).toBe('bar');
act(() => {
setState2('bar-new');
});
[ state1, setState1 ] = result1.current;
[ state2, setState2 ] = result2.current;
expect(state1).toBe('baz-new');
expect(state2).toBe('bar-new');
});
});

0 comments on commit 67acace

Please sign in to comment.