From f81ff294fa843361af8f2879b2f3907103775e55 Mon Sep 17 00:00:00 2001 From: Rommel Manalo Date: Mon, 17 Feb 2020 00:26:14 +0800 Subject: [PATCH 1/2] --escape double quote in lint script to properly find the correct files and folder --uses the base storage, that we can re-used for localStorage and sessionStorage, because they have he same signature -Added the sessionStorage unit test -Update the docs that describe about the config changes, for raw, serializer, de-serializer --- docs/useSessionStorage.md | 11 +- package.json | 2 +- src/useLocalStorage.ts | 81 +--------- src/useSessionStorage.ts | 45 ++---- src/useStorage.ts | 96 +++++++++++ stories/useSessionStorage.story.tsx | 3 +- tests/useSessionStorage.test.ts | 237 ++++++++++++++++++++++++++++ 7 files changed, 358 insertions(+), 117 deletions(-) create mode 100644 src/useStorage.ts create mode 100644 tests/useSessionStorage.test.ts diff --git a/docs/useSessionStorage.md b/docs/useSessionStorage.md index 1ac6956d8f..cc89bd555c 100644 --- a/docs/useSessionStorage.md +++ b/docs/useSessionStorage.md @@ -16,6 +16,7 @@ const Demo = () => {
Value: {value}
+ ); }; @@ -24,12 +25,20 @@ const Demo = () => { ## Reference + ```js useSessionStorage(key); useSessionStorage(key, initialValue); -useSessionStorage(key, initialValue, raw); +useSessionStorage(key, initialValue, { raw: true }); +useSessionStorage(key, initialValue, { + raw: false, + serializer: (value: T) => string, + deserializer: (value: string) => T, +}); ``` - `key` — `sessionStorage` key to manage. - `initialValue` — initial value to set, if value in `sessionStorage` is empty. - `raw` — boolean, if set to `true`, hook will not attempt to JSON serialize stored values. +- `serializer` — custom serializer (defaults to `JSON.stringify`) +- `deserializer` — custom deserializer (defaults to `JSON.parse`) diff --git a/package.json b/package.json index 71eaea04ca..77edceb44c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "jest --maxWorkers 2", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "lint": "eslint '{src,tests}/**/*.{ts,tsx}'", + "lint": "eslint \"{src,tests}/**/*.{ts,tsx}\"", "lint:fix": "yarn lint --fix", "lint:types": "tsc --noEmit", "build:cjs": "tsc", diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 2044e3f6a5..0d6df7ae68 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,88 +1,13 @@ /* eslint-disable */ -import { useState, useCallback, Dispatch, SetStateAction } from 'react'; -import { isClient } from './util'; - -type parserOptions = - | { - raw: true; - } - | { - raw: false; - serializer: (value: T) => string; - deserializer: (value: string) => T; - }; - -const noop = () => {}; +import { Dispatch, SetStateAction } from 'react'; +import useStorage, { parserOptions } from './useStorage'; const useLocalStorage = ( key: string, initialValue?: T, options?: parserOptions ): [T | undefined, Dispatch>, () => void] => { - if (!isClient) { - return [initialValue as T, noop, noop]; - } - if (!key) { - throw new Error('useLocalStorage key may not be falsy'); - } - - const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse; - - const [state, setState] = useState(() => { - try { - 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 { - // If user is in private mode or has storage restriction - // localStorage can throw. JSON.parse and JSON.stringify - // can throw, too. - return initialValue; - } - }); - - const set: Dispatch> = useCallback( - valOrFunc => { - try { - 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. - } - }, - [key, setState] - ); - - const remove = useCallback(() => { - try { - localStorage.removeItem(key); - setState(undefined); - } catch { - // If user is in private mode or has storage restriction - // localStorage can throw. - } - }, [key, setState]); - - return [state, set, remove]; + return useStorage('localStorage', key, initialValue, options); }; export default useLocalStorage; diff --git a/src/useSessionStorage.ts b/src/useSessionStorage.ts index cb3f80f3c6..9c15152686 100644 --- a/src/useSessionStorage.ts +++ b/src/useSessionStorage.ts @@ -1,40 +1,13 @@ /* eslint-disable */ -import { useEffect, useState } from 'react'; -import { isClient } from './util'; - -const useSessionStorage = (key: string, initialValue?: T, raw?: boolean): [T, (value: T) => void] => { - if (!isClient) { - return [initialValue as T, () => {}]; - } - - const [state, setState] = useState(() => { - try { - const sessionStorageValue = sessionStorage.getItem(key); - if (typeof sessionStorageValue !== 'string') { - sessionStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); - return initialValue; - } else { - return raw ? sessionStorageValue : JSON.parse(sessionStorageValue || 'null'); - } - } catch { - // If user is in private mode or has storage restriction - // sessionStorage can throw. JSON.parse and JSON.stringify - // cat throw, too. - return initialValue; - } - }); - - useEffect(() => { - try { - const serializedState = raw ? String(state) : JSON.stringify(state); - sessionStorage.setItem(key, serializedState); - } catch { - // If user is in private mode or has storage restriction - // sessionStorage can throw. Also JSON.stringify can throw. - } - }); - - return [state, setState]; +import { Dispatch, SetStateAction } from 'react'; +import useStorage, { parserOptions } from './useStorage'; + +const useSessionStorage = ( + key: string, + initialValue?: T, + options?: parserOptions +): [T | undefined, Dispatch>, () => void] => { + return useStorage('sessionStorage', key, initialValue, options); }; export default useSessionStorage; diff --git a/src/useStorage.ts b/src/useStorage.ts new file mode 100644 index 0000000000..4c330a3510 --- /dev/null +++ b/src/useStorage.ts @@ -0,0 +1,96 @@ +/* eslint-disable */ +import { useState, useCallback, Dispatch, SetStateAction, useMemo } from 'react'; +import { isClient } from './util'; + +export type parserOptions = + | { + raw: true; + } + | { + raw: false; + serializer: (value: T) => string; + deserializer: (value: string) => T; + }; + +const noop = () => {}; + +const useStorage = ( + storageType: string, + key: string, + initialValue?: T, + options?: parserOptions +): [T | undefined, Dispatch>, () => void] => { + if (!isClient) { + return [initialValue as T, noop, noop]; + } + if (!key) { + throw new Error('Storage key may not be falsy'); + } + //default to localStorage + + let storage = useMemo(() => { + if (storageType == 'sessionStorage') return sessionStorage; + //defaults to localStorage what ever the key was used + else return localStorage; + }, [storageType]); + + const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse; + + const [state, setState] = useState(() => { + try { + const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify; + + const storageValue = storage.getItem(key); + if (storageValue !== null) { + return deserializer(storageValue); + } else { + initialValue && storage.setItem(key, serializer(initialValue)); + return initialValue; + } + } catch { + // If user is in private mode or has storage restriction + // storage can throw. JSON.parse and JSON.stringify + // can throw, too. + return initialValue; + } + }); + + const set: Dispatch> = useCallback( + valOrFunc => { + try { + 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); + + storage.setItem(key, value); + setState(deserializer(value)); + } catch { + // If user is in private mode or has storage restriction + // storage can throw. Also JSON.stringify can throw. + } + }, + [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]); + + return [state, set, remove]; +}; + +export default useStorage; diff --git a/stories/useSessionStorage.story.tsx b/stories/useSessionStorage.story.tsx index fb6a96fc18..28fdf9a4cb 100644 --- a/stories/useSessionStorage.story.tsx +++ b/stories/useSessionStorage.story.tsx @@ -4,13 +4,14 @@ import { useSessionStorage } from '../src'; import ShowDocs from './util/ShowDocs'; const Demo = () => { - const [value, setValue] = useSessionStorage('hello-key', 'foo'); + const [value, setValue, remove] = useSessionStorage('hello-key', 'foo'); return (
Value: {value}
+
); }; diff --git a/tests/useSessionStorage.test.ts b/tests/useSessionStorage.test.ts new file mode 100644 index 0000000000..7f7141ac1b --- /dev/null +++ b/tests/useSessionStorage.test.ts @@ -0,0 +1,237 @@ +/* eslint-disable */ +import useSessionStorage from '../src/useSessionStorage'; +import 'jest-localStorage-mock'; +import { renderHook, act } from '@testing-library/react-hooks'; + +describe(useSessionStorage, () => { + afterEach(() => { + sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('retrieves an existing value from sessionStorage', () => { + sessionStorage.setItem('foo', '"bar"'); + const { result } = renderHook(() => useSessionStorage('foo')); + const [state] = result.current; + expect(state).toEqual('bar'); + }); + + it('should return initialValue if sessionStorage empty and set that to sessionStorage', () => { + const { result } = renderHook(() => useSessionStorage('foo', 'bar')); + const [state] = result.current; + expect(state).toEqual('bar'); + expect(sessionStorage.__STORE__.foo).toEqual('"bar"'); + }); + + it('prefers existing value over initial state', () => { + sessionStorage.setItem('foo', '"bar"'); + const { result } = renderHook(() => useSessionStorage('foo', 'baz')); + const [state] = result.current; + expect(state).toEqual('bar'); + }); + + it('does not clobber existing sessionStorage with initialState', () => { + sessionStorage.setItem('foo', '"bar"'); + const { result } = renderHook(() => useSessionStorage('foo', 'buzz')); + result.current; // invoke current to make sure things are set + expect(sessionStorage.__STORE__.foo).toEqual('"bar"'); + }); + + it('correctly updates sessionStorage', () => { + const { result, rerender } = renderHook(() => useSessionStorage('foo', 'bar')); + + const [, setFoo] = result.current; + act(() => setFoo('baz')); + rerender(); + + expect(sessionStorage.__STORE__.foo).toEqual('"baz"'); + }); + + it('should return undefined if no initialValue provided and sessionStorage empty', () => { + const { result } = renderHook(() => useSessionStorage('some_key')); + + expect(result.current[0]).toBeUndefined(); + }); + + it('returns and allow setting null', () => { + sessionStorage.setItem('foo', 'null'); + const { result, rerender } = renderHook(() => useSessionStorage('foo')); + + const [foo1, setFoo] = result.current; + act(() => setFoo(null)); + rerender(); + + const [foo2] = result.current; + expect(foo1).toEqual(null); + expect(foo2).toEqual(null); + }); + + it('sets initialState if initialState is an object', () => { + renderHook(() => useSessionStorage('foo', { bar: true })); + expect(sessionStorage.__STORE__.foo).toEqual('{"bar":true}'); + }); + + it('correctly and promptly returns a new value', () => { + const { result, rerender } = renderHook(() => useSessionStorage('foo', 'bar')); + + const [, setFoo] = result.current; + act(() => setFoo('baz')); + rerender(); + + const [foo] = result.current; + expect(foo).toEqual('baz'); + }); + + /* + it('keeps multiple hooks accessing the same key in sync', () => { + sessionStorage.setItem('foo', 'bar'); + const { result: r1, rerender: rerender1 } = renderHook(() => useSessionStorage('foo')); + const { result: r2, rerender: rerender2 } = renderHook(() => useSessionStorage('foo')); + + const [, setFoo] = r1.current; + act(() => setFoo('potato')); + rerender1(); + rerender2(); + + const [val1] = r1.current; + const [val2] = r2.current; + + expect(val1).toEqual(val2); + expect(val1).toEqual('potato'); + expect(val2).toEqual('potato'); + }); + */ + + it('parses out objects from sessionStorage', () => { + sessionStorage.setItem('foo', JSON.stringify({ ok: true })); + const { result } = renderHook(() => useSessionStorage<{ ok: boolean }>('foo')); + const [foo] = result.current; + expect(foo!.ok).toEqual(true); + }); + + it('safely initializes objects to sessionStorage', () => { + const { result } = renderHook(() => useSessionStorage<{ ok: boolean }>('foo', { ok: true })); + const [foo] = result.current; + expect(foo!.ok).toEqual(true); + }); + + it('safely sets objects to sessionStorage', () => { + const { result, rerender } = renderHook(() => useSessionStorage<{ ok: any }>('foo', { ok: true })); + + const [, setFoo] = result.current; + act(() => setFoo({ ok: 'bar' })); + rerender(); + + const [foo] = result.current; + expect(foo!.ok).toEqual('bar'); + }); + + it('safely returns objects from updates', () => { + const { result, rerender } = renderHook(() => useSessionStorage<{ ok: any }>('foo', { ok: true })); + + const [, setFoo] = result.current; + act(() => setFoo({ ok: 'bar' })); + rerender(); + + const [foo] = result.current; + expect(foo).toBeInstanceOf(Object); + expect(foo!.ok).toEqual('bar'); + }); + + it('sets sessionStorage from the function updater', () => { + const { result, rerender } = renderHook(() => + useSessionStorage<{ foo: string; fizz?: string }>('foo', { foo: 'bar' }) + ); + + const [, setFoo] = result.current; + act(() => setFoo(state => ({ ...state!, fizz: 'buzz' }))); + rerender(); + + const [value] = result.current; + expect(value!.foo).toEqual('bar'); + expect(value!.fizz).toEqual('buzz'); + }); + + it('rejects nullish or undefined keys', () => { + const { result } = renderHook(() => useSessionStorage(null as any)); + try { + result.current; + fail('hook should have thrown'); + } catch (e) { + expect(String(e)).toMatch(/key may not be/i); + } + }); + + /* Enforces proper eslint react-hooks/rules-of-hooks usage */ + describe('eslint react-hooks/rules-of-hooks', () => { + it('memoizes an object between rerenders', () => { + const { result, rerender } = renderHook(() => useSessionStorage('foo', { ok: true })); + + result.current; // if sessionStorage isn't set then r1 and r2 will be different + rerender(); + const [r2] = result.current; + rerender(); + const [r3] = result.current; + expect(r2).toBe(r3); + }); + + it('memoizes an object immediately if sessionStorage is already set', () => { + sessionStorage.setItem('foo', JSON.stringify({ ok: true })); + const { result, rerender } = renderHook(() => useSessionStorage('foo', { ok: true })); + + const [r1] = result.current; // if sessionStorage isn't set then r1 and r2 will be different + rerender(); + const [r2] = result.current; + expect(r1).toBe(r2); + }); + + it('memoizes the setState function', () => { + sessionStorage.setItem('foo', JSON.stringify({ ok: true })); + const { result, rerender } = renderHook(() => useSessionStorage('foo', { ok: true })); + const [, s1] = result.current; + rerender(); + const [, s2] = result.current; + expect(s1).toBe(s2); + }); + }); + + describe('Options: raw', () => { + it('returns a string when sessionStorage is a stringified object', () => { + sessionStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result } = renderHook(() => useSessionStorage('foo', null, { raw: true })); + const [foo] = result.current; + expect(typeof foo).toBe('string'); + }); + + it('returns a string after an update', () => { + sessionStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result, rerender } = renderHook(() => useSessionStorage('foo', null, { raw: true })); + + const [, setFoo] = result.current; + + act(() => setFoo({ fizz: 'bang' } as any)); + rerender(); + + const [foo] = result.current; + expect(typeof foo).toBe('string'); + + expect(JSON.parse(foo!)).toBeInstanceOf(Object); + + // expect(JSON.parse(foo!).fizz).toEqual('bang'); + }); + + it('still forces setState to a string', () => { + sessionStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result, rerender } = renderHook(() => useSessionStorage('foo', null, { raw: true })); + + const [, setFoo] = result.current; + + act(() => setFoo({ fizz: 'bang' } as any)); + rerender(); + + const [value] = result.current; + + expect(JSON.parse(value!).fizz).toEqual('bang'); + }); + }); +}); From 25fa4f5e3ce40cbc2881b36657950f41059d2c3d Mon Sep 17 00:00:00 2001 From: Rommel Manalo Date: Mon, 17 Feb 2020 01:26:59 +0800 Subject: [PATCH 2/2] fix the useSessionStorage test --- tests/useSessionStorage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/useSessionStorage.test.ts b/tests/useSessionStorage.test.ts index 7f7141ac1b..ff12423bb2 100644 --- a/tests/useSessionStorage.test.ts +++ b/tests/useSessionStorage.test.ts @@ -1,6 +1,6 @@ /* eslint-disable */ import useSessionStorage from '../src/useSessionStorage'; -import 'jest-localStorage-mock'; +import 'jest-localstorage-mock'; import { renderHook, act } from '@testing-library/react-hooks'; describe(useSessionStorage, () => {