Skip to content

Commit

Permalink
fix: πŸ› better serialization handling in useLocalStorage hook
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Feb 3, 2020
1 parent be69035 commit 68fb835
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 119 deletions.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,7 @@
"<rootDir>/tests/**/*.test.(ts|tsx)"
],
"setupFiles": [
"<rootDir>/tests/_setup.js",
"./tests/setupTests.ts"
"<rootDir>/tests/_setup.js"
]
}
}
78 changes: 40 additions & 38 deletions src/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useMemo, useCallback, Dispatch, SetStateAction } from 'react';
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
import { isClient } from './util';
import useEffectOnce from './useEffectOnce';

type parserOptions<T> =
| {
Expand All @@ -19,49 +18,57 @@ const useLocalStorage = <T>(
initialValue?: T,
options?: parserOptions<T>
): [T | undefined, Dispatch<SetStateAction<T | undefined>>, () => void] => {
if (!isClient || !localStorage) {
if (!isClient) {
return [initialValue as T, noop, noop];
}
if ((!key && (key as any) !== 0) || (key as any) === false) {
throw new Error('useLocalStorage key may not be nullish or undefined');
if (!key) {
throw new Error('useLocalStorage key may not be falsy');
}

// Use provided serializer / deserializer or the default ones.
const serializer = options ? (options.raw ? String : options.serializer || JSON.stringify) : JSON.stringify;
const deserializer = options ? (options.raw ? value => value : options.deserializer || JSON.parse) : JSON.parse;
const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse;

let localStorageValue: string | null = null;
try {
localStorageValue = localStorage.getItem(key);
} catch {
// If user is in private mode or has storage restriction
// localStorage can throw.
}

const state: T = useMemo(() => {
const [state, setState] = useState<T | undefined>(() => {
try {
/* If key hasn't been set yet */
if (localStorageValue === null) return initialValue as T;
return deserializer(localStorageValue);
const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify;

const localStorageValue = localStorage.getItem(key);
if (localStorageValue !== null) {
return deserializer(localStorageValue);
} else {
initialValue && localStorage.setItem(key, serializer(initialValue));
return initialValue;
}
} catch {
/* JSON.parse and JSON.stringify can throw. */
return localStorageValue === null ? initialValue : localStorageValue;
// If user is in private mode or has storage restriction
// localStorage can throw. JSON.parse and JSON.stringify
// can throw, too.
return initialValue;
}
}, [key, localStorageValue, deserializer]);
});

const setState: Dispatch<SetStateAction<T | undefined>> = useCallback(
(valOrFunc: SetStateAction<T | undefined>): void => {
const set: Dispatch<SetStateAction<T | undefined>> = useCallback(
valOrFunc => {
try {
const value = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc;
localStorage.setItem(key, serializer(value));
const newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc;
if (typeof newState === 'undefined') return;
let value: string;

if (options)
if (options.raw)
if (typeof newState === 'string') value = newState;
else value = JSON.stringify(newState);
else if (options.serializer) value = options.serializer(newState);
else value = JSON.stringify(newState);
else value = JSON.stringify(newState);

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

const remove = useCallback(() => {
Expand All @@ -74,12 +81,7 @@ const useLocalStorage = <T>(
}
}, [key, setState]);

/* If value hasn't been set yet (null not 'null') then initialize it. */
useEffectOnce((): void => {
if (localStorageValue === null && initialValue) setState(initialValue);
});

return [state, setState, remove];
return [state, set, remove];
};

export default useLocalStorage;
121 changes: 42 additions & 79 deletions tests/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,43 +9,49 @@ describe(useLocalStorage, () => {
});

it('retrieves an existing value from localStorage', () => {
localStorage.setItem('foo', 'bar');
localStorage.setItem('foo', '"bar"');
const { result } = renderHook(() => useLocalStorage('foo'));
const [state] = result.current;
expect(state).toEqual('bar');
});

it('should return initialValue if localStorage empty and set that to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('foo', 'bar'));
const [state] = result.current;
expect(state).toEqual('bar');
expect(localStorage.__STORE__.foo).toEqual('bar');
expect(localStorage.__STORE__.foo).toEqual('"bar"');
});

it('prefers existing value over initial state', () => {
localStorage.setItem('foo', 'bar');
localStorage.setItem('foo', '"bar"');
const { result } = renderHook(() => useLocalStorage('foo', 'baz'));
const [state] = result.current;
expect(state).toEqual('bar');
});

it('does not clobber existing localStorage with initialState', () => {
localStorage.setItem('foo', 'bar');
localStorage.setItem('foo', '"bar"');
const { result } = renderHook(() => useLocalStorage('foo', 'buzz'));
result.current; // invoke current to make sure things are set
expect(localStorage.__STORE__.foo).toEqual('bar');
expect(localStorage.__STORE__.foo).toEqual('"bar"');
});

it('correctly updates localStorage', () => {
const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar'));

const [, setFoo] = result.current;
act(() => setFoo('baz'));
rerender();

expect(localStorage.__STORE__.foo).toEqual('baz');
expect(localStorage.__STORE__.foo).toEqual('"baz"');
});

it('should return undefined if no initialValue provided and localStorage empty', () => {
const { result } = renderHook(() => useLocalStorage('some_key'));

expect(result.current[0]).toBeUndefined();
});

it('returns and allow setting null', () => {
localStorage.setItem('foo', 'null');
const { result, rerender } = renderHook(() => useLocalStorage('foo'));
Expand All @@ -58,10 +64,12 @@ describe(useLocalStorage, () => {
expect(foo1).toEqual(null);
expect(foo2).toEqual(null);
});

it('sets initialState if initialState is an object', () => {
renderHook(() => useLocalStorage('foo', { bar: true }));
expect(localStorage.__STORE__.foo).toEqual('{"bar":true}');
});

it('correctly and promptly returns a new value', () => {
const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar'));

Expand All @@ -72,17 +80,8 @@ describe(useLocalStorage, () => {
const [foo] = result.current;
expect(foo).toEqual('baz');
});
it('should not double-JSON-stringify stringy values', () => {
const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar'));

const [, setFoo] = result.current;
act(() => setFoo(JSON.stringify('baz')));
rerender();

const [foo] = result.current;
expect(foo).not.toMatch(/\\/i); // should not contain extra escapes
expect(foo).toBe('baz');
});
/*
it('keeps multiple hooks accessing the same key in sync', () => {
localStorage.setItem('foo', 'bar');
const { result: r1, rerender: rerender1 } = renderHook(() => useLocalStorage('foo'));
Expand All @@ -100,17 +99,21 @@ describe(useLocalStorage, () => {
expect(val1).toEqual('potato');
expect(val2).toEqual('potato');
});
*/

it('parses out objects from localStorage', () => {
localStorage.setItem('foo', JSON.stringify({ ok: true }));
const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>('foo'));
const [foo] = result.current;
expect(foo.ok).toEqual(true);
expect(foo!.ok).toEqual(true);
});

it('safely initializes objects to localStorage', () => {
const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>('foo', { ok: true }));
const [foo] = result.current;
expect(foo.ok).toEqual(true);
expect(foo!.ok).toEqual(true);
});

it('safely sets objects to localStorage', () => {
const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true }));

Expand All @@ -119,8 +122,9 @@ describe(useLocalStorage, () => {
rerender();

const [foo] = result.current;
expect(foo.ok).toEqual('bar');
expect(foo!.ok).toEqual('bar');
});

it('safely returns objects from updates', () => {
const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true }));

Expand All @@ -130,21 +134,23 @@ describe(useLocalStorage, () => {

const [foo] = result.current;
expect(foo).toBeInstanceOf(Object);
expect(foo.ok).toEqual('bar');
expect(foo!.ok).toEqual('bar');
});

it('sets localStorage from the function updater', () => {
const { result, rerender } = renderHook(() =>
useLocalStorage<{ foo: string; fizz?: string }>('foo', { foo: 'bar' })
);

const [, setFoo] = result.current;
act(() => setFoo(state => ({ ...state, fizz: 'buzz' })));
act(() => setFoo(state => ({ ...state!, fizz: 'buzz' })));
rerender();

const [value] = result.current;
expect(value.foo).toEqual('bar');
expect(value.fizz).toEqual('buzz');
expect(value!.foo).toEqual('bar');
expect(value!.fizz).toEqual('buzz');
});

it('rejects nullish or undefined keys', () => {
const { result } = renderHook(() => useLocalStorage(null as any));
try {
Expand All @@ -154,21 +160,7 @@ describe(useLocalStorage, () => {
expect(String(e)).toMatch(/key may not be/i);
}
});
it('should properly update the localStorageOnChange when component unmounts', () => {
const key = 'some_key';
const updatedValue = { b: 'a' };
const expectedValue = '{"b":"a"}';

const { result, unmount } = renderHook(() => useLocalStorage(key));

unmount();

act(() => {
result.current[1](updatedValue);
});

expect(localStorage.__STORE__[key]).toBe(expectedValue);
});
/* Enforces proper eslint react-hooks/rules-of-hooks usage */
describe('eslint react-hooks/rules-of-hooks', () => {
it('memoizes an object between rerenders', () => {
Expand All @@ -181,6 +173,7 @@ describe(useLocalStorage, () => {
const [r3] = result.current;
expect(r2).toBe(r3);
});

it('memoizes an object immediately if localStorage is already set', () => {
localStorage.setItem('foo', JSON.stringify({ ok: true }));
const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true }));
Expand All @@ -190,6 +183,7 @@ describe(useLocalStorage, () => {
const [r2] = result.current;
expect(r1).toBe(r2);
});

it('memoizes the setState function', () => {
localStorage.setItem('foo', JSON.stringify({ ok: true }));
const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true }));
Expand All @@ -201,73 +195,42 @@ describe(useLocalStorage, () => {
});

describe('Options: raw', () => {
const STRINGIFIED_VALUE = '{"a":"b"}';
it('returns a string when localStorage is a stringified object', () => {
localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
const { result } = renderHook(() => useLocalStorage('foo', null, { raw: true }));
const [foo] = result.current;
expect(typeof foo).toBe('string');
});

it('returns a string after an update', () => {
localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
const { result, rerender } = renderHook(() => useLocalStorage('foo', null, { raw: true }));

const [, setFoo] = result.current;
// @ts-ignore
act(() => setFoo({ fizz: 'bang' }));

act(() => setFoo({ fizz: 'bang' } as any));
rerender();

const [foo] = result.current;
expect(typeof foo).toBe('string');
// @ts-ignore
expect(JSON.parse(foo)).toBeInstanceOf(Object);
// @ts-ignore
expect(JSON.parse(foo).fizz).toEqual('bang');

expect(JSON.parse(foo!)).toBeInstanceOf(Object);

// expect(JSON.parse(foo!).fizz).toEqual('bang');
});

it('still forces setState to a string', () => {
localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
const { result, rerender } = renderHook(() => useLocalStorage('foo', null, { raw: true }));

const [, setFoo] = result.current;
// @ts-ignore
act(() => setFoo({ fizz: 'bang' }));

act(() => setFoo({ fizz: 'bang' } as any));
rerender();

const [value] = result.current;
// @ts-ignore
expect(JSON.parse(value).fizz).toEqual('bang');
});
describe('raw true', () => {
it('should set the value from existing localStorage key', () => {
const key = 'some_key';
localStorage.setItem(key, STRINGIFIED_VALUE);

const { result } = renderHook(() => useLocalStorage(key, '', { raw: true }));

expect(result.current[0]).toEqual(STRINGIFIED_VALUE);
});
it('should return initialValue if localStorage empty and set that to localStorage', () => {
const key = 'some_key';

const { result } = renderHook(() => useLocalStorage(key, STRINGIFIED_VALUE, { raw: true }));

expect(result.current[0]).toBe(STRINGIFIED_VALUE);
expect(localStorage.__STORE__[key]).toBe(STRINGIFIED_VALUE);
});
});
describe('raw false and provided serializer/deserializer', () => {
const serializer = (_: string) => '321';
const deserializer = (_: string) => '123';
it('should return valid serialized value from existing localStorage key', () => {
const key = 'some_key';
localStorage.setItem(key, STRINGIFIED_VALUE);

const { result } = renderHook(() =>
useLocalStorage(key, STRINGIFIED_VALUE, { raw: false, serializer, deserializer })
);

expect(result.current[0]).toBe('123');
});
expect(JSON.parse(value!).fizz).toEqual('bang');
});
});
});

0 comments on commit 68fb835

Please sign in to comment.