From 641a879a51851127893d9407c1294c0403117daa Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 4 Sep 2019 11:29:26 +0300 Subject: [PATCH 01/58] useAsyncFn and useAsync typings made great again =) Now it uses fully generic arguments and returning types, thus no problems with inferring callback call arguments; --- src/useAsync.ts | 11 ++++------- src/useAsyncFn.ts | 23 ++++++++++------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/useAsync.ts b/src/useAsync.ts index e20b0b68b6..9046b31da0 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -1,13 +1,10 @@ import { DependencyList, useEffect } from 'react'; -import useAsyncFn from './useAsyncFn'; +import useAsyncFn, { FnReturningPromise } from './useAsyncFn'; -export { AsyncState, AsyncFn } from './useAsyncFn'; +export { AsyncState, AsyncFnReturn } from './useAsyncFn'; -export default function useAsync( - fn: (...args: Args | []) => Promise, - deps: DependencyList = [] -) { - const [state, callback] = useAsyncFn(fn, deps, { +export default function useAsync(fn: T, deps: DependencyList = []) { + const [state, callback] = useAsyncFn(fn, deps, { loading: true, }); diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index dd2fc33e9f..cfdb17b608 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -18,21 +18,18 @@ export type AsyncState = value: T; }; -export type AsyncFn = [ - AsyncState, - (...args: Args | []) => Promise -]; +export type FnReturningPromise = (...args: any[]) => Promise; +export type AsyncFnReturn = [AsyncState>, T]; -export default function useAsyncFn( - fn: (...args: Args | []) => Promise, +export default function useAsync( + fn: T, deps: DependencyList = [], - initialState: AsyncState = { loading: false } -): AsyncFn { - const [state, set] = useState>(initialState); - + initialState: AsyncState> = { loading: false } +): AsyncFnReturn { const isMounted = useMountedState(); + const [state, set] = useState>>(initialState); - const callback = useCallback((...args: Args | []) => { + const callback = useCallback((...args: Parameters): ReturnType => { set({ loading: true }); return fn(...args).then( @@ -46,8 +43,8 @@ export default function useAsyncFn( return error; } - ); + ) as ReturnType; }, deps); - return [state, callback]; + return [state, (callback as unknown) as T]; } From 563f71db14d0f2acc7dda8246f6a0ed41ec93d68 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 4 Sep 2019 13:30:40 +0300 Subject: [PATCH 02/58] Fix Promise type detection; Fix function name; --- src/useAsync.ts | 3 ++- src/useAsyncFn.ts | 12 +++++++----- src/util.ts | 4 ++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/useAsync.ts b/src/useAsync.ts index 9046b31da0..887326d9b9 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -1,5 +1,6 @@ import { DependencyList, useEffect } from 'react'; -import useAsyncFn, { FnReturningPromise } from './useAsyncFn'; +import useAsyncFn from './useAsyncFn'; +import { FnReturningPromise } from './util'; export { AsyncState, AsyncFnReturn } from './useAsyncFn'; diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index cfdb17b608..ca5701ab65 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -1,5 +1,6 @@ import { DependencyList, useCallback, useState } from 'react'; import useMountedState from './useMountedState'; +import { FnReturningPromise, PromiseType } from './util'; export type AsyncState = | { @@ -18,16 +19,17 @@ export type AsyncState = value: T; }; -export type FnReturningPromise = (...args: any[]) => Promise; -export type AsyncFnReturn = [AsyncState>, T]; +type StateFromFnReturningPromise = AsyncState>>; -export default function useAsync( +export type AsyncFnReturn = [StateFromFnReturningPromise, T]; + +export default function useAsyncFn( fn: T, deps: DependencyList = [], - initialState: AsyncState> = { loading: false } + initialState: StateFromFnReturningPromise = { loading: false } ): AsyncFnReturn { const isMounted = useMountedState(); - const [state, set] = useState>>(initialState); + const [state, set] = useState>(initialState); const callback = useCallback((...args: Parameters): ReturnType => { set({ loading: true }); diff --git a/src/util.ts b/src/util.ts index 5e44fdcf75..37e60b7fe7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,3 +3,7 @@ export const isClient = typeof window === 'object'; export const on = (obj: any, ...args: any[]) => obj.addEventListener(...args); export const off = (obj: any, ...args: any[]) => obj.removeEventListener(...args); + +export type FnReturningPromise = (...args: any[]) => Promise; + +export type PromiseType

> = P extends Promise ? T : never; From 5cfddaf9b44137cc5b83fe8b032406d57a81edea Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 19:59:04 -0800 Subject: [PATCH 03/58] add useLocalStorage tests --- package.json | 1 + tests/useLocalStorage.test.ts | 130 ++++++++++++++++++++++++++++++++++ yarn.lock | 5 ++ 3 files changed, 136 insertions(+) create mode 100644 tests/useLocalStorage.test.ts diff --git a/package.json b/package.json index f1f1862225..6640fcad9a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "gh-pages": "2.1.1", "husky": "3.1.0", "jest": "24.9.0", + "jest-localstorage-mock": "^2.4.0", "keyboardjs": "2.5.1", "lint-staged": "9.4.3", "markdown-loader": "5.1.0", diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts new file mode 100644 index 0000000000..c4b2329529 --- /dev/null +++ b/tests/useLocalStorage.test.ts @@ -0,0 +1,130 @@ +import useLocalStorage from "../src/useLocalStorage"; +import "jest-localstorage-mock"; +import { renderHook, act } from "@testing-library/react-hooks"; + +describe(useLocalStorage, () => { + afterEach(() => localStorage.clear()); + it("retrieves an existing value from localStorage", () => { + localStorage.setItem("foo", "bar"); + const { result } = renderHook(() => useLocalStorage("foo")); + const [state] = result.current; + expect(state).toEqual("bar"); + }); + it("sets initial state", () => { + const { result } = renderHook(() => useLocalStorage("foo", "bar")); + const [state] = result.current; + expect(state).toEqual("bar"); + expect(localStorage.__STORE__["foo"]).toEqual("bar"); + }); + it("prefers existing value over initial state", () => { + 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') + const { result } = renderHook(() => useLocalStorage('foo', 'buzz')); + result.current; // invoke current to make sure things are set + 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"); + }); + it("correctly and promptly returns a new value", () => { + const { result, rerender } = renderHook(() => useLocalStorage("foo", "bar")); + + const [, setFoo] = result.current; + act(() => setFoo("baz")); + rerender(); + + 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).toEqual("baz"); + }); + it("keeps multiple hooks accessing the same key in sync", () => { + localStorage.setItem("foo", "bar"); + const { result: r1, rerender: rerender1 } = renderHook(() => useLocalStorage("foo")); + const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage("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 localStorage", () => { + localStorage.setItem("foo", JSON.stringify({ ok: true })); + const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>("foo")); + const [foo] = result.current; + 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); + }); + it("safely sets objects to localStorage", () => { + const { result, rerender } = renderHook(() => useLocalStorage<{ 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(() => useLocalStorage<{ 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 localStorage from the function updater", () => { + const { result, rerender } = renderHook(() => + useLocalStorage<{ foo: string; fizz?: string }>("foo", { foo: "bar" }) + ); + + const [, setFoo] = result.current; + act(() => + setFoo(state => { + console.log(state); + return { ...state, fizz: "buzz" }; + }) + ); + rerender(); + + const [value] = result.current; + + console.log(value); + + expect(value.foo).toEqual("bar"); + expect(value.fizz).toEqual("buzz"); + }); +}); diff --git a/yarn.lock b/yarn.lock index fa8eaaa0fa..8b28813a4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7384,6 +7384,11 @@ jest-leak-detector@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" +jest-localstorage-mock@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.0.tgz#c6073810735dd3af74020ea6c3885ec1cc6d0d13" + integrity sha512-/mC1JxnMeuIlAaQBsDMilskC/x/BicsQ/BXQxEOw+5b1aGZkkOAqAF3nu8yq449CpzGtp5jJ5wCmDNxLgA2m6A== + jest-matcher-utils@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073" From da4bfddb26c680e2f6ea1bf712d1118320834e01 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 22:24:59 -0800 Subject: [PATCH 04/58] finish tests and hook --- src/useLocalStorage.ts | 69 +++++++++++++++++++++-------------- tests/useLocalStorage.test.ts | 45 ++++++++++++++++++----- 2 files changed, 76 insertions(+), 38 deletions(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 051685be63..c803c152df 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,42 +1,55 @@ -import { useEffect, useState } from 'react'; import { isClient } from './util'; +import { useMemo, useCallback, useEffect, Dispatch, SetStateAction } from 'react'; -type Dispatch = (value: A) => void; -type SetStateAction = S | ((prevState: S) => S); - -const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, Dispatch>] => { - if (!isClient) { +const useLocalStorage = ( + key: string, + initialValue?: any, + raw?: boolean +): [any, Dispatch>] => { + if (!isClient || !localStorage) { return [initialValue as T, () => {}]; } - const [state, setState] = useState(() => { + let localStorageValue: string | null = null; + try { + localStorageValue = localStorage.getItem(key); + } catch { + // If user is in private mode or has storage restriction + // localStorage can throw. + localStorageValue = initialValue; + } + + const state = useMemo(() => { try { - const localStorageValue = localStorage.getItem(key); - if (typeof localStorageValue !== 'string') { - localStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); - return initialValue; - } else { - return raw ? localStorageValue : JSON.parse(localStorageValue || 'null'); - } + if (localStorageValue === null) return initialValue; // key hasn't been set yet + return raw ? localStorageValue : JSON.parse(localStorageValue); } catch { - // If user is in private mode or has storage restriction - // localStorage can throw. JSON.parse and JSON.stringify - // can throw, too. - return initialValue; + /* JSON.parse and JSON.stringify can throw. */ + return localStorageValue === null ? initialValue : localStorageValue; } - }); + }, [key, localStorageValue, initialValue]); + + const setState = useCallback( + (valOrFunc: any) => { + try { + let newState = typeof valOrFunc === 'function' ? valOrFunc(state) : valOrFunc; + newState = typeof newState === 'string' ? newState : JSON.stringify(newState); + localStorage.setItem(key, newState); + } catch { + /** + * If user is in private mode or has storage restriction + * localStorage can throw. Also JSON.stringify can throw. + */ + } + }, + [state, raw] + ); useEffect(() => { - try { - const serializedState = raw ? String(state) : JSON.stringify(state); - localStorage.setItem(key, serializedState); - } catch { - // If user is in private mode or has storage restriction - // localStorage can throw. Also JSON.stringify can throw. - } - }, [state]); + if (localStorageValue === null) setState(initialValue); + }, [localStorageValue, setState]); - return [state, setState]; + return [state as any, setState]; }; export default useLocalStorage; diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index c4b2329529..816cfa7e78 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -55,7 +55,8 @@ describe(useLocalStorage, () => { rerender(); const [foo] = result.current; - expect(foo).toEqual("baz"); + 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"); @@ -112,19 +113,43 @@ describe(useLocalStorage, () => { ); const [, setFoo] = result.current; - act(() => - setFoo(state => { - console.log(state); - return { ...state, fizz: "buzz" }; - }) - ); + act(() => setFoo(state => ({ ...state, fizz: "buzz" }))); rerender(); const [value] = result.current; - - console.log(value); - expect(value.foo).toEqual("bar"); expect(value.fizz).toEqual("buzz"); }); + describe("raw setting", () => { + it('returns a string when localStorage is a stringified object', () => { + localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result } = renderHook(() => useLocalStorage('foo', null, 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, true)); + + const [,setFoo] = result.current; + act(() => setFoo({ fizz: 'bang' })) + 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', () => { + localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result, rerender } = renderHook(() => useLocalStorage('foo', null, true)); + + const [,setFoo] = result.current; + act(() => setFoo({ fizz: 'bang' })) + rerender(); + + const [value] = result.current; + expect(JSON.parse(value).fizz).toEqual('bang'); + }); + }); }); From 126c5091395cfe1ddb4b2bb9d4ff310db69dc532 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 22:46:35 -0800 Subject: [PATCH 05/58] reject nullish keys --- src/useLocalStorage.ts | 3 +++ tests/useLocalStorage.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index c803c152df..18567aeb31 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -9,6 +9,9 @@ const useLocalStorage = ( if (!isClient || !localStorage) { return [initialValue as T, () => {}]; } + if (!key && (key as any) !== 0) { + throw new Error('useLocalStorage key may not be nullish or undefined'); + } let localStorageValue: string | null = null; try { diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 816cfa7e78..2970882f1f 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -152,4 +152,13 @@ describe(useLocalStorage, () => { expect(JSON.parse(value).fizz).toEqual('bang'); }); }); + it('rejects nullish or undefined keys', () => { + const { result } = renderHook(() => useLocalStorage(null as any)); + try { + result.current; + fail('hook should have thrown'); + } catch (e) { + expect(String(e)).toMatch(/key may not be/i); + } + }); }); From ead224287509898fcac2d8e86bf1e6135df66bc9 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 23:04:34 -0800 Subject: [PATCH 06/58] reintroduce types --- src/useLocalStorage.ts | 24 ++++++++++-------------- tests/useLocalStorage.test.ts | 30 +++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 18567aeb31..e3d2366660 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,11 +1,7 @@ import { isClient } from './util'; import { useMemo, useCallback, useEffect, Dispatch, SetStateAction } from 'react'; -const useLocalStorage = ( - key: string, - initialValue?: any, - raw?: boolean -): [any, Dispatch>] => { +const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, Dispatch>] => { if (!isClient || !localStorage) { return [initialValue as T, () => {}]; } @@ -19,12 +15,12 @@ const useLocalStorage = ( } catch { // If user is in private mode or has storage restriction // localStorage can throw. - localStorageValue = initialValue; } - const state = useMemo(() => { + const state: T = useMemo(() => { try { - if (localStorageValue === null) return initialValue; // key hasn't been set yet + /* If key hasn't been set yet */ + if (localStorageValue === null) return initialValue as T; return raw ? localStorageValue : JSON.parse(localStorageValue); } catch { /* JSON.parse and JSON.stringify can throw. */ @@ -32,10 +28,10 @@ const useLocalStorage = ( } }, [key, localStorageValue, initialValue]); - const setState = useCallback( - (valOrFunc: any) => { + const setState: Dispatch> = useCallback( + (valOrFunc: SetStateAction): void => { try { - let newState = typeof valOrFunc === 'function' ? valOrFunc(state) : valOrFunc; + let newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc; newState = typeof newState === 'string' ? newState : JSON.stringify(newState); localStorage.setItem(key, newState); } catch { @@ -48,11 +44,11 @@ const useLocalStorage = ( [state, raw] ); - useEffect(() => { - if (localStorageValue === null) setState(initialValue); + useEffect((): void => { + if (localStorageValue === null && initialValue) setState(initialValue); }, [localStorageValue, setState]); - return [state as any, setState]; + return [state, setState]; }; export default useLocalStorage; diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 2970882f1f..bb29a612e3 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -37,6 +37,18 @@ describe(useLocalStorage, () => { expect(localStorage.__STORE__["foo"]).toEqual("baz"); }); + it("returns and allow setting null", () => { + localStorage.setItem('foo', 'null'); + const { result, rerender } = renderHook(() => useLocalStorage('foo')); + + const [foo1, setFoo] = result.current; + act(() => setFoo(null)); + rerender(); + + const [foo2] = result.current; + expect(foo1).toEqual(null); + expect(foo2).toEqual(null); + }); it("correctly and promptly returns a new value", () => { const { result, rerender } = renderHook(() => useLocalStorage("foo", "bar")); @@ -120,6 +132,15 @@ describe(useLocalStorage, () => { expect(value.foo).toEqual("bar"); expect(value.fizz).toEqual("buzz"); }); + it('rejects nullish or undefined keys', () => { + const { result } = renderHook(() => useLocalStorage(null as any)); + try { + result.current; + fail('hook should have thrown'); + } catch (e) { + expect(String(e)).toMatch(/key may not be/i); + } + }); describe("raw setting", () => { it('returns a string when localStorage is a stringified object', () => { localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); @@ -152,13 +173,4 @@ describe(useLocalStorage, () => { expect(JSON.parse(value).fizz).toEqual('bang'); }); }); - it('rejects nullish or undefined keys', () => { - const { result } = renderHook(() => useLocalStorage(null as any)); - try { - result.current; - fail('hook should have thrown'); - } catch (e) { - expect(String(e)).toMatch(/key may not be/i); - } - }); }); From affcbbd404a22d830ab832ddcfce9976d237ada0 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 23:36:38 -0800 Subject: [PATCH 07/58] run prettier --- tests/useLocalStorage.test.ts | 122 +++++++++++++++++----------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index bb29a612e3..47c3d3dfe1 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -1,43 +1,43 @@ -import useLocalStorage from "../src/useLocalStorage"; -import "jest-localstorage-mock"; -import { renderHook, act } from "@testing-library/react-hooks"; +import useLocalStorage from '../src/useLocalStorage'; +import 'jest-localstorage-mock'; +import { renderHook, act } from '@testing-library/react-hooks'; describe(useLocalStorage, () => { afterEach(() => localStorage.clear()); - it("retrieves an existing value from localStorage", () => { - localStorage.setItem("foo", "bar"); - const { result } = renderHook(() => useLocalStorage("foo")); + it('retrieves an existing value from localStorage', () => { + localStorage.setItem('foo', 'bar'); + const { result } = renderHook(() => useLocalStorage('foo')); const [state] = result.current; - expect(state).toEqual("bar"); + expect(state).toEqual('bar'); }); - it("sets initial state", () => { - const { result } = renderHook(() => useLocalStorage("foo", "bar")); + it('sets initial state', () => { + const { result } = renderHook(() => useLocalStorage('foo', 'bar')); const [state] = result.current; - expect(state).toEqual("bar"); - expect(localStorage.__STORE__["foo"]).toEqual("bar"); + expect(state).toEqual('bar'); + expect(localStorage.__STORE__.foo).toEqual('bar'); }); - it("prefers existing value over initial state", () => { - localStorage.setItem("foo", "bar"); - const { result } = renderHook(() => useLocalStorage("foo", "baz")); + it('prefers existing value over initial state', () => { + localStorage.setItem('foo', 'bar'); + const { result } = renderHook(() => useLocalStorage('foo', 'baz')); const [state] = result.current; - expect(state).toEqual("bar"); + expect(state).toEqual('bar'); }); - it("does not clobber existing localStorage with initialState", () => { - localStorage.setItem('foo', 'bar') + it('does not clobber existing localStorage with initialState', () => { + 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'); - }) - it("correctly updates localStorage", () => { - const { result, rerender } = renderHook(() => useLocalStorage("foo", "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")); + act(() => setFoo('baz')); rerender(); - expect(localStorage.__STORE__["foo"]).toEqual("baz"); + expect(localStorage.__STORE__.foo).toEqual('baz'); }); - it("returns and allow setting null", () => { + it('returns and allow setting null', () => { localStorage.setItem('foo', 'null'); const { result, rerender } = renderHook(() => useLocalStorage('foo')); @@ -49,34 +49,34 @@ describe(useLocalStorage, () => { expect(foo1).toEqual(null); expect(foo2).toEqual(null); }); - it("correctly and promptly returns a new value", () => { - const { result, rerender } = renderHook(() => useLocalStorage("foo", "bar")); + it('correctly and promptly returns a new value', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar')); const [, setFoo] = result.current; - act(() => setFoo("baz")); + act(() => setFoo('baz')); rerender(); const [foo] = result.current; - expect(foo).toEqual("baz"); + expect(foo).toEqual('baz'); }); - it("should not double-JSON-stringify stringy values", () => { - const { result, rerender } = renderHook(() => useLocalStorage("foo", "bar")); + it('should not double-JSON-stringify stringy values', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar')); const [, setFoo] = result.current; - act(() => setFoo(JSON.stringify("baz"))); + 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")); - const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage("foo")); + it('keeps multiple hooks accessing the same key in sync', () => { + localStorage.setItem('foo', 'bar'); + const { result: r1, rerender: rerender1 } = renderHook(() => useLocalStorage('foo')); + const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage('foo')); const [, setFoo] = r1.current; - act(() => setFoo("potato")); + act(() => setFoo('potato')); rerender1(); rerender2(); @@ -84,53 +84,53 @@ describe(useLocalStorage, () => { const [val2] = r2.current; expect(val1).toEqual(val2); - expect(val1).toEqual("potato"); - expect(val2).toEqual("potato"); + 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")); + 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); }); - it("safely initializes objects to localStorage", () => { - const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>("foo", { ok: 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); }); - it("safely sets objects to localStorage", () => { - const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>("foo", { ok: true })); + it('safely sets objects to localStorage', () => { + const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true })); const [, setFoo] = result.current; - act(() => setFoo({ ok: "bar" })); + act(() => setFoo({ ok: 'bar' })); 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 })); + it('safely returns objects from updates', () => { + const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true })); const [, setFoo] = result.current; - act(() => setFoo({ ok: "bar" })); + act(() => setFoo({ ok: 'bar' })); rerender(); 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", () => { + it('sets localStorage from the function updater', () => { const { result, rerender } = renderHook(() => - useLocalStorage<{ foo: string; fizz?: string }>("foo", { foo: "bar" }) + 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)); @@ -141,7 +141,7 @@ describe(useLocalStorage, () => { expect(String(e)).toMatch(/key may not be/i); } }); - describe("raw setting", () => { + describe('raw setting', () => { it('returns a string when localStorage is a stringified object', () => { localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); const { result } = renderHook(() => useLocalStorage('foo', null, true)); @@ -152,8 +152,8 @@ describe(useLocalStorage, () => { localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); const { result, rerender } = renderHook(() => useLocalStorage('foo', null, true)); - const [,setFoo] = result.current; - act(() => setFoo({ fizz: 'bang' })) + const [, setFoo] = result.current; + act(() => setFoo({ fizz: 'bang' })); rerender(); const [foo] = result.current; @@ -165,8 +165,8 @@ describe(useLocalStorage, () => { localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); const { result, rerender } = renderHook(() => useLocalStorage('foo', null, true)); - const [,setFoo] = result.current; - act(() => setFoo({ fizz: 'bang' })) + const [, setFoo] = result.current; + act(() => setFoo({ fizz: 'bang' })); rerender(); const [value] = result.current; From 683e6581bf82bd5767bef035a4bebfc86cae5e20 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Tue, 19 Nov 2019 23:55:35 -0800 Subject: [PATCH 08/58] add some ignores to tests --- tests/useLocalStorage.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 47c3d3dfe1..81e9375880 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -153,12 +153,15 @@ describe(useLocalStorage, () => { const { result, rerender } = renderHook(() => useLocalStorage('foo', null, true)); const [, setFoo] = result.current; + // @ts-ignore act(() => setFoo({ fizz: 'bang' })); 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'); }); it('still forces setState to a string', () => { @@ -166,10 +169,12 @@ describe(useLocalStorage, () => { const { result, rerender } = renderHook(() => useLocalStorage('foo', null, true)); const [, setFoo] = result.current; + // @ts-ignore act(() => setFoo({ fizz: 'bang' })); rerender(); const [value] = result.current; + // @ts-ignore expect(JSON.parse(value).fizz).toEqual('bang'); }); }); From b702692d233f580060cad48da0d068b1ecd07232 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Wed, 20 Nov 2019 00:17:03 -0800 Subject: [PATCH 09/58] enforces rules of hooks --- src/useLocalStorage.ts | 2 +- tests/useLocalStorage.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index e3d2366660..9ab2c110ef 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -26,7 +26,7 @@ const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, D /* JSON.parse and JSON.stringify can throw. */ return localStorageValue === null ? initialValue : localStorageValue; } - }, [key, localStorageValue, initialValue]); + }, [key, localStorageValue]); const setState: Dispatch> = useCallback( (valOrFunc: SetStateAction): void => { diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 81e9375880..3a1872fbef 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -178,4 +178,34 @@ describe(useLocalStorage, () => { expect(JSON.parse(value).fizz).toEqual('bang'); }); }); + /* 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(() => useLocalStorage('foo', { ok: true })); + + result.current; // if localStorage 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 localStorage is already set', () => { + localStorage.setItem('foo', JSON.stringify({ ok: true })); + const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true })); + + const [r1] = result.current; // if localStorage isn't set then r1 and r2 will be different + rerender(); + 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 })); + const [, s1] = result.current; + rerender(); + const [, s2] = result.current; + expect(s1).toBe(s2); + }); + }); }); From c8396443b4e4c5162c31452805702f0a4029fa54 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Mon, 25 Nov 2019 21:37:35 -0800 Subject: [PATCH 10/58] initialState switched to useEffectOnce --- src/useLocalStorage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 9ab2c110ef..734c9e54ac 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,5 +1,6 @@ import { isClient } from './util'; -import { useMemo, useCallback, useEffect, Dispatch, SetStateAction } from 'react'; +import { useMemo, useCallback, Dispatch, SetStateAction } from 'react'; +import useEffectOnce from './useEffectOnce'; const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, Dispatch>] => { if (!isClient || !localStorage) { @@ -44,9 +45,10 @@ const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, D [state, raw] ); - useEffect((): void => { + /* If value hasn't been set yet (null not 'null') then initialize it. */ + useEffectOnce((): void => { if (localStorageValue === null && initialValue) setState(initialValue); - }, [localStorageValue, setState]); + }); return [state, setState]; }; From 41f9452722d6fb7d2628480d7ce657e4f08e441a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 28 Nov 2019 17:27:21 +0100 Subject: [PATCH 11/58] fix: use latest set object in useSet "has" method --- src/useSet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useSet.ts b/src/useSet.ts index eb79a32be0..e7a32fde8b 100644 --- a/src/useSet.ts +++ b/src/useSet.ts @@ -17,7 +17,7 @@ const useSet = (initialSet = new Set()): [Set, Actions] => { remove: item => setSet(prevSet => new Set(Array.from(prevSet).filter(i => i !== item))), reset: () => setSet(initialSet), }), - [setSet] + [set, setSet] ); return [set, utils]; From 587de16ef5c85497d01e63247a578116d0605ff9 Mon Sep 17 00:00:00 2001 From: suyingtao Date: Fri, 13 Dec 2019 18:45:01 +0800 Subject: [PATCH 12/58] feat(useLocalStorage): add remove feature. (#229) --- docs/useLocalStorage.md | 3 ++- src/useLocalStorage.ts | 34 +++++++++++++++++++++++-------- stories/useLocalStorage.story.tsx | 7 +++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/useLocalStorage.md b/docs/useLocalStorage.md index a414fdad56..d7040eef9b 100644 --- a/docs/useLocalStorage.md +++ b/docs/useLocalStorage.md @@ -9,13 +9,14 @@ React side-effect hook that manages a single `localStorage` key. import {useLocalStorage} from 'react-use'; const Demo = () => { - const [value, setValue] = useLocalStorage('my-key', 'foo'); + const [value, setValue, remove] = useLocalStorage('my-key', 'foo'); return (

); }; diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 051685be63..0415f06090 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,23 +1,31 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { isClient } from './util'; type Dispatch = (value: A) => void; type SetStateAction = S | ((prevState: S) => S); -const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, Dispatch>] => { +const noop = () => {}; + +const useLocalStorage = ( + key: string, + initialValue?: T, + raw?: boolean +): [T | null, Dispatch>, () => void] => { if (!isClient) { - return [initialValue as T, () => {}]; + return [initialValue as T, noop, noop]; } - const [state, setState] = useState(() => { + const [state, setState] = useState(() => { try { const localStorageValue = localStorage.getItem(key); + if (typeof initialValue === 'undefined' && typeof localStorageValue !== 'string') { + return null; + } if (typeof localStorageValue !== 'string') { localStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); return initialValue; - } else { - return raw ? localStorageValue : JSON.parse(localStorageValue || 'null'); } + return raw ? localStorageValue : JSON.parse(localStorageValue || 'null'); } catch { // If user is in private mode or has storage restriction // localStorage can throw. JSON.parse and JSON.stringify @@ -26,7 +34,18 @@ const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, D } }); + const remove = useCallback(() => { + try { + localStorage.removeItem(key); + setState(null); + } catch { + // If user is in private mode or has storage restriction + // localStorage can throw. + } + }, [key, setState]); + useEffect(() => { + if (state === null) return; try { const serializedState = raw ? String(state) : JSON.stringify(state); localStorage.setItem(key, serializedState); @@ -35,8 +54,7 @@ const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, D // localStorage can throw. Also JSON.stringify can throw. } }, [state]); - - return [state, setState]; + return [state, setState, remove]; }; export default useLocalStorage; diff --git a/stories/useLocalStorage.story.tsx b/stories/useLocalStorage.story.tsx index 31f8e87003..f246c9607f 100644 --- a/stories/useLocalStorage.story.tsx +++ b/stories/useLocalStorage.story.tsx @@ -5,12 +5,19 @@ import ShowDocs from './util/ShowDocs'; const Demo = () => { const [value, setValue] = useLocalStorage('hello-key', 'foo'); + const [removableValue, setRemovableValue, remove] = useLocalStorage('removeable-key'); return (
Value: {value}
+
+
+
Removable Value: {removableValue}
+ + +
); }; From cb0ed5f7584914d8d40ae1c3e83fd7d13707a818 Mon Sep 17 00:00:00 2001 From: MHA15 <30382711+MHA15@users.noreply.github.com> Date: Sun, 15 Dec 2019 12:44:51 +0330 Subject: [PATCH 13/58] change useUpdate hook to use `reducer` instead of `state + callback` if we using reducer instead of useState then we don't need a useCallback and it's performance will increase --- src/useUpdate.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/useUpdate.ts b/src/useUpdate.ts index d7f0d36853..1977b07ca9 100644 --- a/src/useUpdate.ts +++ b/src/useUpdate.ts @@ -1,11 +1,10 @@ import { useCallback, useState } from 'react'; -const incrementParameter = (num: number): number => ++num % 1_000_000; +const updateReducer = (num: number): number => (num + 1) % 1_000_000; const useUpdate = () => { - const [, setState] = useState(0); - // useCallback with empty deps as we only want to define updateCb once - return useCallback(() => setState(incrementParameter), []); + const [, update] = useReducer(updateReducer, 0); + return update; }; export default useUpdate; From 036d1c2f1f0f418e056bef199dbd5fb310ab289a Mon Sep 17 00:00:00 2001 From: MHA15 <30382711+MHA15@users.noreply.github.com> Date: Sun, 15 Dec 2019 18:16:55 +0330 Subject: [PATCH 14/58] fix import problems --- src/useUpdate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useUpdate.ts b/src/useUpdate.ts index 1977b07ca9..f4e08e2bb9 100644 --- a/src/useUpdate.ts +++ b/src/useUpdate.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useReducer } from 'react'; const updateReducer = (num: number): number => (num + 1) % 1_000_000; From baab9df948ad35c7e4bb76aec664ecc8f3a9fdd1 Mon Sep 17 00:00:00 2001 From: MHA15 <30382711+MHA15@users.noreply.github.com> Date: Sun, 15 Dec 2019 18:30:41 +0330 Subject: [PATCH 15/58] fix ts definition to get no argument for dispatch function --- src/useUpdate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useUpdate.ts b/src/useUpdate.ts index f4e08e2bb9..8a50dcb2f6 100644 --- a/src/useUpdate.ts +++ b/src/useUpdate.ts @@ -4,7 +4,7 @@ const updateReducer = (num: number): number => (num + 1) % 1_000_000; const useUpdate = () => { const [, update] = useReducer(updateReducer, 0); - return update; + return update as (() => void); }; export default useUpdate; From 1620e019fff94fb4a7a711fd3121ec02c7e99301 Mon Sep 17 00:00:00 2001 From: suyingtao Date: Mon, 16 Dec 2019 12:59:03 +0800 Subject: [PATCH 16/58] fix(useLocalStorage): using undefined for empty value instead of null --- src/useLocalStorage.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 0415f06090..7e6a3a64c1 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -5,21 +5,22 @@ type Dispatch
= (value: A) => void; type SetStateAction = S | ((prevState: S) => S); const noop = () => {}; +const isUndefined = (value?: any): boolean => typeof value === 'undefined'; const useLocalStorage = ( key: string, initialValue?: T, raw?: boolean -): [T | null, Dispatch>, () => void] => { +): [T | undefined, Dispatch>, () => void] => { if (!isClient) { return [initialValue as T, noop, noop]; } - const [state, setState] = useState(() => { + const [state, setState] = useState(() => { try { const localStorageValue = localStorage.getItem(key); - if (typeof initialValue === 'undefined' && typeof localStorageValue !== 'string') { - return null; + if (isUndefined(initialValue)) { + return undefined; } if (typeof localStorageValue !== 'string') { localStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); @@ -37,7 +38,7 @@ const useLocalStorage = ( const remove = useCallback(() => { try { localStorage.removeItem(key); - setState(null); + setState(undefined); } catch { // If user is in private mode or has storage restriction // localStorage can throw. @@ -45,7 +46,7 @@ const useLocalStorage = ( }, [key, setState]); useEffect(() => { - if (state === null) return; + if (isUndefined(state)) return; try { const serializedState = raw ? String(state) : JSON.stringify(state); localStorage.setItem(key, serializedState); From 733cf9bb072d16bc7a5432c5bb7ccd09a71c6301 Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 31 Dec 2019 00:04:29 +0100 Subject: [PATCH 17/58] =?UTF-8?q?test:=20=F0=9F=92=8D=20fix=20useAsync=20t?= =?UTF-8?q?est?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/useAsync.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/useAsync.test.tsx b/tests/useAsync.test.tsx index 3993b6bc70..98fa253229 100644 --- a/tests/useAsync.test.tsx +++ b/tests/useAsync.test.tsx @@ -148,7 +148,7 @@ describe('useAsync', () => { hook = renderHook( ({ fn, counter }) => { const callback = useCallback(() => fn(counter), [counter]); - return useAsync(callback, [callback]); + return useAsync(callback, [callback]); }, { initialProps: { From 90ba9d000ff35039028cb66753114a6b0b452491 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 5 Jan 2020 10:07:19 +0100 Subject: [PATCH 18/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20remove=20set=20depe?= =?UTF-8?q?ndencies=20in=20useSet=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useSet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useSet.ts b/src/useSet.ts index 680f8dceec..01a9ca2330 100644 --- a/src/useSet.ts +++ b/src/useSet.ts @@ -19,7 +19,7 @@ const useSet = (initialSet = new Set()): [Set, Actions] => { remove: item => setSet(prevSet => new Set(Array.from(prevSet).filter(i => i !== item))), reset: () => setSet(initialSet), }), - [set, setSet] + [setSet] ); const utils = { From e232bcc6fe9faae8a955d31a17aac5c01141dd80 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 5 Jan 2020 10:08:40 +0100 Subject: [PATCH 19/58] Release 14.0.0-alpha.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8b03ffeb2c..b64c6a8c27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "13.13.0", + "version": "14.0.0-alpha.0", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 091c9077ad52d72c720482df2290b862cbac3fa2 Mon Sep 17 00:00:00 2001 From: Alexey Bojhev <9medved@mail.ru> Date: Fri, 10 Jan 2020 03:57:56 +0300 Subject: [PATCH 20/58] upgrade useCustomCompareEffect.ts Added generic useCustomCompareEffect for dependencies inference in compare function --- src/useCustomCompareEffect.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/useCustomCompareEffect.ts b/src/useCustomCompareEffect.ts index 8f189188e4..28cd635044 100644 --- a/src/useCustomCompareEffect.ts +++ b/src/useCustomCompareEffect.ts @@ -2,9 +2,16 @@ import { DependencyList, EffectCallback, useEffect, useRef } from 'react'; const isPrimitive = (val: any) => val !== Object(val); -type DepsEqualFnType = (prevDeps: DependencyList, nextDeps: DependencyList) => boolean; - -const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, depsEqual: DepsEqualFnType) => { +type DepsEqualFnType = ( + prevDeps: TDeps, + nextDeps: TDeps +) => boolean; + +const useCustomCompareEffect = ( + effect: EffectCallback, + deps: TDeps, + depsEqual: DepsEqualFnType +) => { if (process.env.NODE_ENV !== 'production') { if (!(deps instanceof Array) || !deps.length) { console.warn('`useCustomCompareEffect` should not be used with no dependencies. Use React.useEffect instead.'); @@ -21,7 +28,7 @@ const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, de } } - const ref = useRef(undefined); + const ref = useRef(undefined); if (!ref.current || !depsEqual(deps, ref.current)) { ref.current = deps; From 4d8824064a0afbeba5a15597b007f8463fdbe027 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:11:35 +0100 Subject: [PATCH 21/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20improve=20implemen?= =?UTF-8?q?tation=20of=20useMeasure()=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: useMeasure() now defaults all values to -1, if they were not set and internal implementation heavily refactored. --- src/useMeasure.ts | 61 +++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 4622355f6a..29ac5ad8db 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,39 +1,48 @@ -import { useCallback, useState } from 'react'; +import { useState, useMemo } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; +import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; -export type ContentRect = Pick; +export type UseMeasureRect = Pick< + DOMRectReadOnly, + 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' +>; +export type UseMeasureRef = (element: HTMLElement) => void; +export type UseMeasureResult = [UseMeasureRef, UseMeasureRect]; -const useMeasure = (): [(instance: T) => void, ContentRect] => { - const [rect, set] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); +const defaultState: UseMeasureRect = { + x: -1, + y: -1, + width: -1, + height: -1, + top: -1, + left: -1, + bottom: -1, + right: -1, +}; + +const useMeasure = (): UseMeasureResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(defaultState); - const [observer] = useState( + const observer = useMemo( () => new ResizeObserver(entries => { - const entry = entries[0]; - if (entry) { - set(entry.contentRect); + if (entries[0]) { + const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; + setRect({ x, y, width, height, top, left, bottom, right }); } - }) + }), + [] ); - const ref = useCallback( - node => { + useIsomorphicLayoutEffect(() => { + if (!element) return; + observer.observe(element); + return () => { observer.disconnect(); - if (node) { - observer.observe(node); - } - }, - [observer] - ); + }; + }, [element]); + return [ref, rect]; }; From 2bbc73a5f08e9a21bb3054527fc8ff9fd51cfd47 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:14:26 +0100 Subject: [PATCH 22/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20mock=20useMeasure(?= =?UTF-8?q?)=20hook=20on=20server=20and=20w/o=20ResizeObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useMeasure.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 29ac5ad8db..37af79aae8 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -46,4 +46,6 @@ const useMeasure = (): UseMeasureResult => { return [ref, rect]; }; -export default useMeasure; +const useMeasureMock = () => [() => {}, defaultState]; + +export default !!(window as any).ResizeObserver ? useMeasure : useMeasureMock; From 4dfb25870f7d30ef14ecc6178e7efdce6b12f59d Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:17:21 +0100 Subject: [PATCH 23/58] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20mention=20Re?= =?UTF-8?q?sizeObserver=20polyfill=20in=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/useMeasure.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/useMeasure.md b/docs/useMeasure.md index d8e87a0cad..306fec3b6a 100644 --- a/docs/useMeasure.md +++ b/docs/useMeasure.md @@ -25,6 +25,21 @@ const Demo = () => { }; ``` +This hook uses [`ResizeObserver` API][resize-observer], if you want to support +legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] +before running your app. + +```js +if (!window.ResizeObserver) { + window.ResizeObserver = (await import('resize-observer-polyfill')).default; +} +``` + + ## Related hooks - [useSize](./useSize.md) + + +[resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver +[resize-observer-polyfill]: https://www.npmjs.com/package/resize-observer-polyfill From bf11131052c4a4ab2b9306486f0b171ac15057b0 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:48:07 +0100 Subject: [PATCH 24/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20remove=20resize-ob?= =?UTF-8?q?server-polyfill=20from=20useMeasure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: resize-observer-polyfill package is not used with useMeasure() hook anymore. --- package.json | 3 + src/useMeasure.ts | 3 +- tests/_setup.js | 5 + tests/useMeasure.test.ts | 239 ++++++++++++++++++++++++++------------- 4 files changed, 171 insertions(+), 79 deletions(-) create mode 100644 tests/_setup.js diff --git a/package.json b/package.json index b64c6a8c27..0db04b2e5d 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,9 @@ "coverageDirectory": "coverage", "testMatch": [ "/tests/**/*.test.(ts|tsx)" + ], + "setupFiles": [ + "/tests/_setup.js" ] } } diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 37af79aae8..57d5c6d3fb 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,5 +1,4 @@ import { useState, useMemo } from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; export type UseMeasureRect = Pick< @@ -26,7 +25,7 @@ const useMeasure = (): UseMeasureResult => { const observer = useMemo( () => - new ResizeObserver(entries => { + new (window as any).ResizeObserver(entries => { if (entries[0]) { const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; setRect({ x, y, width, height, top, left, bottom, right }); diff --git a/tests/_setup.js b/tests/_setup.js new file mode 100644 index 0000000000..b4816e4044 --- /dev/null +++ b/tests/_setup.js @@ -0,0 +1,5 @@ +window.ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + disconnect() {} +}; diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index 6d9da805b3..33c6f805d4 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -1,88 +1,79 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -import useMeasure, { ContentRect } from '../src/useMeasure'; - -interface Entry { - target: HTMLElement; - contentRect: ContentRect; -} - -jest.mock('resize-observer-polyfill', () => { - return class ResizeObserver { - private cb: (entries: Entry[]) => void; - private map: WeakMap; - private targets: HTMLElement[]; - constructor(cb: () => void) { - this.cb = cb; - this.map = new WeakMap(); - this.targets = []; - } - public disconnect() { - this.targets.map(target => { - const originMethod = this.map.get(target); - target.setAttribute = originMethod; - this.map.delete(target); - }); - } - public observe(target: HTMLElement) { - const method = 'setAttribute'; - const originMethod = target[method]; - this.map.set(target, originMethod); - this.targets.push(target); - target[method] = (...args) => { - const [attrName, value] = args; - if (attrName === 'style') { - const rect: DOMRectReadOnly = { - x: 0, - y: 0, - top: 0, - left: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - } as DOMRectReadOnly; - value.split(';').map(kv => { - const [key, v] = kv.split(':'); - if (['top', 'bottom', 'left', 'right', 'width', 'height'].includes(key)) { - rect[key] = parseInt(v, 10); - } - }); - target.getBoundingClientRect = () => rect; - } - originMethod.apply(target, args); - this.fireCallback(); - }; - } - private fireCallback() { - if (this.cb) { - this.cb( - this.targets.map(target => { - return { - target, - contentRect: target.getBoundingClientRect() as ContentRect, - }; - }) - ); - } +import { renderHook, act } from '@testing-library/react-hooks'; +import useMeasure, { UseMeasureRef } from '../src/useMeasure'; + +it('by default, state defaults every value to -1', () => { + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(result.current[1]).toMatchObject({ + width: -1, + height: -1, + top: -1, + bottom: -1, + left: -1, + right: -1, + }); +}); + +it('synchronously sets up ResizeObserver listener', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; } + observe() {} + disconnect() {} }; + + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(typeof listener).toBe('function'); }); -it('reacts to changes in size of any of the observed elements', () => { +it('tracks rectangle of a DOM element', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + observe() {} + disconnect() {} + }; + const { result } = renderHook(() => useMeasure()); - const div = document.createElement('div'); - result.current[0](div); - expect(result.current[1]).toMatchObject({ - width: 0, - height: 0, - top: 0, - bottom: 0, - left: 0, - right: 0, + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + act(() => { + listener!([{ + contentRect: { + x: 1, + y: 2, + width: 200, + height: 200, + top: 100, + bottom: 0, + left: 100, + right: 0, + } + }]); }); - act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100')); expect(result.current[1]).toMatchObject({ + x: 1, + y: 2, width: 200, height: 200, top: 100, @@ -91,3 +82,97 @@ it('reacts to changes in size of any of the observed elements', () => { right: 0, }); }); + +it('tracks multiple updates', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + observe() {} + disconnect() {} + }; + + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + act(() => { + listener!([{ + contentRect: { + x: 1, + y: 1, + width: 1, + height: 1, + top: 1, + bottom: 1, + left: 1, + right: 1, + } + }]); + }); + + expect(result.current[1]).toMatchObject({ + x: 1, + y: 1, + width: 1, + height: 1, + top: 1, + bottom: 1, + left: 1, + right: 1, + }); + + act(() => { + listener!([{ + contentRect: { + x: 2, + y: 2, + width: 2, + height: 2, + top: 2, + bottom: 2, + left: 2, + right: 2, + } + }]); + }); + + expect(result.current[1]).toMatchObject({ + x: 2, + y: 2, + width: 2, + height: 2, + top: 2, + bottom: 2, + left: 2, + right: 2, + }); +}); + +it('calls .disconnect() on ResizeObserver when component unmounts', () => { + const disconnect = jest.fn(); + (window as any).ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + disconnect() { + disconnect(); + } + }; + + const { result, unmount } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(disconnect).toHaveBeenCalledTimes(0); + + unmount(); + + expect(disconnect).toHaveBeenCalledTimes(1); +}); From def75839d7cb950a9308731414df9ed315f3ef31 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:51:08 +0100 Subject: [PATCH 25/58] =?UTF-8?q?style:=20=F0=9F=92=84=20run=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/useMeasure.test.ts | 98 +++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index 33c6f805d4..1f74e9e3ef 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -20,7 +20,7 @@ it('by default, state defaults every value to -1', () => { }); it('synchronously sets up ResizeObserver listener', () => { - let listener: ((rect: any) => void) | undefined = undefined; + let listener: ((rect: any) => void) | undefined; (window as any).ResizeObserver = class ResizeObserver { constructor(ls) { listener = ls; @@ -30,7 +30,7 @@ it('synchronously sets up ResizeObserver listener', () => { }; const { result } = renderHook(() => useMeasure()); - + act(() => { const div = document.createElement('div'); (result.current[0] as UseMeasureRef)(div); @@ -40,7 +40,7 @@ it('synchronously sets up ResizeObserver listener', () => { }); it('tracks rectangle of a DOM element', () => { - let listener: ((rect: any) => void) | undefined = undefined; + let listener: ((rect: any) => void) | undefined; (window as any).ResizeObserver = class ResizeObserver { constructor(ls) { listener = ls; @@ -50,25 +50,27 @@ it('tracks rectangle of a DOM element', () => { }; const { result } = renderHook(() => useMeasure()); - + act(() => { const div = document.createElement('div'); (result.current[0] as UseMeasureRef)(div); }); - + act(() => { - listener!([{ - contentRect: { - x: 1, - y: 2, - width: 200, - height: 200, - top: 100, - bottom: 0, - left: 100, - right: 0, - } - }]); + listener!([ + { + contentRect: { + x: 1, + y: 2, + width: 200, + height: 200, + top: 100, + bottom: 0, + left: 100, + right: 0, + }, + }, + ]); }); expect(result.current[1]).toMatchObject({ @@ -84,7 +86,7 @@ it('tracks rectangle of a DOM element', () => { }); it('tracks multiple updates', () => { - let listener: ((rect: any) => void) | undefined = undefined; + let listener: ((rect: any) => void) | undefined; (window as any).ResizeObserver = class ResizeObserver { constructor(ls) { listener = ls; @@ -94,25 +96,27 @@ it('tracks multiple updates', () => { }; const { result } = renderHook(() => useMeasure()); - + act(() => { const div = document.createElement('div'); (result.current[0] as UseMeasureRef)(div); }); - + act(() => { - listener!([{ - contentRect: { - x: 1, - y: 1, - width: 1, - height: 1, - top: 1, - bottom: 1, - left: 1, - right: 1, - } - }]); + listener!([ + { + contentRect: { + x: 1, + y: 1, + width: 1, + height: 1, + top: 1, + bottom: 1, + left: 1, + right: 1, + }, + }, + ]); }); expect(result.current[1]).toMatchObject({ @@ -125,20 +129,22 @@ it('tracks multiple updates', () => { left: 1, right: 1, }); - + act(() => { - listener!([{ - contentRect: { - x: 2, - y: 2, - width: 2, - height: 2, - top: 2, - bottom: 2, - left: 2, - right: 2, - } - }]); + listener!([ + { + contentRect: { + x: 2, + y: 2, + width: 2, + height: 2, + top: 2, + bottom: 2, + left: 2, + right: 2, + }, + }, + ]); }); expect(result.current[1]).toMatchObject({ @@ -164,7 +170,7 @@ it('calls .disconnect() on ResizeObserver when component unmounts', () => { }; const { result, unmount } = renderHook(() => useMeasure()); - + act(() => { const div = document.createElement('div'); (result.current[0] as UseMeasureRef)(div); From 58db2f989d5d4f75ac5e8ef54c25a9df8bb173a5 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 18:04:37 +0100 Subject: [PATCH 26/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20useScratch()?= =?UTF-8?q?=20sensor=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/useScratch.ts | 180 ++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 7 ++ 3 files changed, 188 insertions(+) create mode 100644 src/useScratch.ts diff --git a/package.json b/package.json index b64c6a8c27..ae9bb65c55 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "fast-shallow-equal": "^0.1.1", "nano-css": "^5.2.1", "react-fast-compare": "^2.0.4", + "react-universal-interface": "^0.6.0", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "set-harmonic-interval": "^1.0.1", diff --git a/src/useScratch.ts b/src/useScratch.ts new file mode 100644 index 0000000000..7c9dd32a9d --- /dev/null +++ b/src/useScratch.ts @@ -0,0 +1,180 @@ +import { useState, useEffect, useRef, FC, cloneElement } from 'react'; +import { render } from 'react-universal-interface'; + +const noop = () => {}; + +export interface ScratchSensorParams { + disabled?: boolean; + onScratch?: (state: ScratchSensorState) => void; + onScratchStart?: (state: ScratchSensorState) => void; + onScratchEnd?: (state: ScratchSensorState) => void; +} + +export interface ScratchSensorState { + isScratching: boolean; + start?: number; + end?: number; + x?: number; + y?: number; + dx?: number; + dy?: number; + docX?: number; + docY?: number; + posX?: number; + posY?: number; + elH?: number; + elW?: number; + elX?: number; + elY?: number; +} + +const useScratch = ({ + disabled, + onScratch = noop, + onScratchStart = noop, + onScratchEnd = noop, +}: ScratchSensorParams = {}): [ScratchSensorState, (el: HTMLElement | null) => void] => { + const [state, setState] = useState({ isScratching: false }); + const refState = useRef(state); + const refScratching = useRef(false); + const refAnimationFrame = useRef(null); + const [el, setEl] = useState(null); + useEffect(() => { + if (disabled) return; + if (!el) return; + + const onMoveEvent = (docX, docY) => { + cancelAnimationFrame(refAnimationFrame.current); + refAnimationFrame.current = requestAnimationFrame(() => { + const { left, top } = el.getBoundingClientRect(); + const elX = left + window.scrollX; + const elY = top + window.scrollY; + const x = docX - elX; + const y = docY - elY; + setState(oldState => { + const newState = { + ...oldState, + dx: x - (oldState.x || 0), + dy: y - (oldState.y || 0), + end: Date.now(), + isScratching: true, + }; + refState.current = newState; + onScratch(newState); + return newState; + }); + }); + }; + + const onMouseMove = event => { + onMoveEvent(event.pageX, event.pageY); + }; + + const onTouchMove = event => { + onMoveEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY); + }; + + let onMouseUp; + let onTouchEnd; + + const stopScratching = () => { + if (!refScratching.current) return; + refScratching.current = false; + refState.current = { ...refState.current, isScratching: false }; + onScratchEnd(refState.current); + setState({ isScratching: false }); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchend', onTouchEnd); + }; + + onMouseUp = stopScratching; + onTouchEnd = stopScratching; + + const startScratching = (docX, docY) => { + if (!refScratching.current) return; + const { left, top } = el.getBoundingClientRect(); + const elX = left + window.scrollX; + const elY = top + window.scrollY; + const x = docX - elX; + const y = docY - elY; + const time = Date.now(); + const newState = { + isScratching: true, + start: time, + end: time, + docX, + docY, + x, + y, + dx: 0, + dy: 0, + elH: el.offsetHeight, + elW: el.offsetWidth, + elX, + elY, + }; + refState.current = newState; + onScratchStart(newState); + setState(newState); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('touchmove', onTouchMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchend', onTouchEnd); + }; + + const onMouseDown = event => { + refScratching.current = true; + startScratching(event.pageX, event.pageY); + }; + + const onTouchStart = event => { + refScratching.current = true; + startScratching(event.changedTouches[0].pageX, event.changedTouches[0].pageY); + }; + + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('touchstart', onTouchStart); + + return () => { + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('touchstart', onTouchStart); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchend', onTouchEnd); + + if (refAnimationFrame.current) cancelAnimationFrame(refAnimationFrame.current); + refAnimationFrame.current = null; + + refScratching.current = false; + refState.current = { isScratching: false }; + setState(refState.current); + }; + }, [el, disabled]); + + return [state, setEl]; +}; + +export interface ScratchSensorProps extends ScratchSensorParams { + children: (state: ScratchSensorState, ref: (el: HTMLElement | null) => void) => React.ReactElement; +} + +export const ScratchSensor: FC = props => { + const { children, ...params } = props; + const [state, ref] = useScratch(params); + const element = render(props, state); + return cloneElement(element, { + ...element.props, + ref: el => { + if (element.props.ref) { + if (typeof element.props.ref === 'object') element.props.ref.current = el; + if (typeof element.props.ref === 'function') element.props.ref(el); + } + ref(el); + }, + }); +}; + +export default useScratch; diff --git a/yarn.lock b/yarn.lock index 72017e6c4c..81ea8a6ebd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11554,6 +11554,13 @@ react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-universal-interface@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.0.tgz#b65cbf7d71a2f3f7dd9705d8e4f06748539bd465" + integrity sha512-PzApKKWfd7gvDi1sU/D07jUqnLvFxYqvJi+GEtLvBO5tXJjKr2Sa8ETVHkMA7Jcvdwt7ttbPq7Sed1JpFdNqBQ== + dependencies: + tslib "^1.9.3" + react@16.12.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" From 4ca2d9b29e0c2badb758ad14ec855f038b611de6 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 18:17:56 +0100 Subject: [PATCH 27/58] =?UTF-8?q?test:=20=F0=9F=92=8D=20add=20useScratch()?= =?UTF-8?q?=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 1 + src/useScratch.ts | 6 ++--- stories/useScratch.story.tsx | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 stories/useScratch.story.tsx diff --git a/src/index.ts b/src/index.ts index b5f89826bd..e7800172ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export { default as useRaf } from './useRaf'; export { default as useRafLoop } from './useRafLoop'; export { default as useRafState } from './useRafState'; export { default as useSearchParam } from './useSearchParam'; +export { default as useScratch } from './useScratch'; export { default as useScroll } from './useScroll'; export { default as useScrolling } from './useScrolling'; export { default as useSessionStorage } from './useSessionStorage'; diff --git a/src/useScratch.ts b/src/useScratch.ts index 7c9dd32a9d..35fafc65cd 100644 --- a/src/useScratch.ts +++ b/src/useScratch.ts @@ -33,7 +33,7 @@ const useScratch = ({ onScratch = noop, onScratchStart = noop, onScratchEnd = noop, -}: ScratchSensorParams = {}): [ScratchSensorState, (el: HTMLElement | null) => void] => { +}: ScratchSensorParams = {}): [(el: HTMLElement | null) => void, ScratchSensorState] => { const [state, setState] = useState({ isScratching: false }); const refState = useRef(state); const refScratching = useRef(false); @@ -154,7 +154,7 @@ const useScratch = ({ }; }, [el, disabled]); - return [state, setEl]; + return [setEl, state]; }; export interface ScratchSensorProps extends ScratchSensorParams { @@ -163,7 +163,7 @@ export interface ScratchSensorProps extends ScratchSensorParams { export const ScratchSensor: FC = props => { const { children, ...params } = props; - const [state, ref] = useScratch(params); + const [ref, state] = useScratch(params); const element = render(props, state); return cloneElement(element, { ...element.props, diff --git a/stories/useScratch.story.tsx b/stories/useScratch.story.tsx new file mode 100644 index 0000000000..49fe1edb2c --- /dev/null +++ b/stories/useScratch.story.tsx @@ -0,0 +1,46 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useScratch } from '../src'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [ref, state] = useScratch(); + + const blockStyle: React.CSSProperties = { + position: 'relative', + width: 400, + height: 400, + border: '1px solid tomato', + }; + + const preStyle: React.CSSProperties = { + pointerEvents: 'none', + userSelect: 'none', + }; + + let { x = 0, y = 0, dx = 0, dy = 0 } = state; + if (dx < 0) [x, dx] = [x + dx, -dx]; + if (dy < 0) [y, dy] = [y + dy, -dy]; + + const rectangleStyle: React.CSSProperties = { + position: 'absolute', + left: x, + top: y, + width: dx, + height: dy, + border: '1px solid tomato', + pointerEvents: 'none', + userSelect: 'none', + }; + + return ( +
+
{JSON.stringify(state, null, 4)}
+ {state.isScratching &&
} +
+ ); +}; + +storiesOf('Sensors/useScratch', module) + // .add('Docs', () => ) + .add('Demo', () => ); From 9e651b8908c07044fde4d0e90c3dbc7e177f512d Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 18:21:43 +0100 Subject: [PATCH 28/58] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20add=20useScr?= =?UTF-8?q?atch=20to=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/useScratch.md | 75 ++++++++++++++++++++++++++++++++++++ stories/useScratch.story.tsx | 2 +- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/useScratch.md diff --git a/README.md b/README.md index e86e38c42e..e7b1b4b66b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - [`useNetwork`](./docs/useNetwork.md) — tracks state of user's internet connection. - [`useOrientation`](./docs/useOrientation.md) — tracks state of device's screen orientation. - [`usePageLeave`](./docs/usePageLeave.md) — triggers when mouse leaves page boundaries. + - [`useScratch`](./docs/useScratch.md) — tracks mouse click-and-scrub state. - [`useScroll`](./docs/useScroll.md) — tracks an HTML element's scroll position. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usescroll--docs) - [`useScrolling`](./docs/useScrolling.md) — tracks whether HTML element is scrolling. - [`useSize`](./docs/useSize.md) — tracks an HTML element's size. diff --git a/docs/useScratch.md b/docs/useScratch.md new file mode 100644 index 0000000000..0ba6c31108 --- /dev/null +++ b/docs/useScratch.md @@ -0,0 +1,75 @@ +# `useScratch` + +React sensor hook that tracks state of mouse "scrubs" (or "scratches"). + +## Usage + +```jsx +import useScratch from 'react-use/lib/useScratch'; + +const Demo = () => { + const [ref, state] = useScratch(); + + const blockStyle: React.CSSProperties = { + position: 'relative', + width: 400, + height: 400, + border: '1px solid tomato', + }; + + const preStyle: React.CSSProperties = { + pointerEvents: 'none', + userSelect: 'none', + }; + + let { x = 0, y = 0, dx = 0, dy = 0 } = state; + if (dx < 0) [x, dx] = [x + dx, -dx]; + if (dy < 0) [y, dy] = [y + dy, -dy]; + + const rectangleStyle: React.CSSProperties = { + position: 'absolute', + left: x, + top: y, + width: dx, + height: dy, + border: '1px solid tomato', + pointerEvents: 'none', + userSelect: 'none', + }; + + return ( +
+
{JSON.stringify(state, null, 4)}
+ {state.isScratching &&
} +
+ ); +}; +``` + +## Reference + +```ts +const [ref, state] = useScratch(); +``` + +`state` is: + +```ts +export interface ScratchSensorState { + isScratching: boolean; + start?: number; + end?: number; + x?: number; + y?: number; + dx?: number; + dy?: number; + docX?: number; + docY?: number; + posX?: number; + posY?: number; + elH?: number; + elW?: number; + elX?: number; + elY?: number; +} +``` diff --git a/stories/useScratch.story.tsx b/stories/useScratch.story.tsx index 49fe1edb2c..43e9e31b3d 100644 --- a/stories/useScratch.story.tsx +++ b/stories/useScratch.story.tsx @@ -42,5 +42,5 @@ const Demo = () => { }; storiesOf('Sensors/useScratch', module) - // .add('Docs', () => ) + .add('Docs', () => ) .add('Demo', () => ); From 4bf9f9389f925964dc71fc816959bead745df4c3 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 18:29:29 +0100 Subject: [PATCH 29/58] Release 14.0.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61ceed7a63..897df195d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.0.0-alpha.0", + "version": "14.0.0-alpha.1", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 61dcb1f1e23c2233a24b31f3ab986cbaa66b00b0 Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Wed, 15 Jan 2020 20:31:10 -0600 Subject: [PATCH 30/58] test: add use copy to clipboard test --- tests/useCopyToClipboard.test.ts | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/useCopyToClipboard.test.ts diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts new file mode 100644 index 0000000000..a292fe8122 --- /dev/null +++ b/tests/useCopyToClipboard.test.ts @@ -0,0 +1,41 @@ +import writeText from 'copy-to-clipboard'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useCopyToClipboard } from '../src'; + +jest.mock('copy-to-clipboard', () => jest.fn().mockImplementation((value: any) => typeof value === 'string')); + +describe('useCopyToClipboard', () => { + let hook; + + beforeEach(() => { + hook = renderHook(() => useCopyToClipboard()); + }); + + it('should be defined ', () => { + expect(useCopyToClipboard).toBeDefined(); + }); + + it('should pass a given value to copy to clipboard and update the state value if no error', () => { + const testValue = 'test'; + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).not.toBeDefined(); + }); + + it('should set the corresponding noUserInteraction value if returned from copy to clipboard', () => { + const testValue = {}; // invalid value + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(false); + expect(state.error).toBeDefined(); + }); +}); From b13667b55c2747a9285503f4bbf6c1c9b458dada Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 18:55:50 -0600 Subject: [PATCH 31/58] test: add more test cases --- tests/useCopyToClipboard.test.ts | 39 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts index a292fe8122..84910b226c 100644 --- a/tests/useCopyToClipboard.test.ts +++ b/tests/useCopyToClipboard.test.ts @@ -2,7 +2,16 @@ import writeText from 'copy-to-clipboard'; import { renderHook, act } from '@testing-library/react-hooks'; import { useCopyToClipboard } from '../src'; -jest.mock('copy-to-clipboard', () => jest.fn().mockImplementation((value: any) => typeof value === 'string')); +const valueToRaiseMockException = 'fake input causing exception in copy to clipboard'; + +jest.mock('copy-to-clipboard', () => + jest.fn().mockImplementation(input => { + if (input === valueToRaiseMockException) { + throw new Error(input); + } + return true; + }) +); describe('useCopyToClipboard', () => { let hook; @@ -15,7 +24,7 @@ describe('useCopyToClipboard', () => { expect(useCopyToClipboard).toBeDefined(); }); - it('should pass a given value to copy to clipboard and update the state value if no error', () => { + it('should pass a given value to copy to clipboard and set state', () => { const testValue = 'test'; let [state, copyToClipboard] = hook.result.current; act(() => copyToClipboard(testValue)); @@ -27,15 +36,35 @@ describe('useCopyToClipboard', () => { expect(state.error).not.toBeDefined(); }); - it('should set the corresponding noUserInteraction value if returned from copy to clipboard', () => { + it('should only call writeText if passed a valid input and set state', () => { const testValue = {}; // invalid value let [state, copyToClipboard] = hook.result.current; act(() => copyToClipboard(testValue)); [state, copyToClipboard] = hook.result.current; - expect(writeText).toBeCalled(); + expect(writeText).not.toBeCalled(); expect(state.value).toBe(testValue); - expect(state.noUserInteraction).toBe(false); + expect(state.noUserInteraction).toBe(true); expect(state.error).toBeDefined(); }); + + it('should catch exception thrown by copy-to-clipboard and set state', () => { + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(valueToRaiseMockException)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).toBeCalledWith(valueToRaiseMockException); + expect(state.value).toBe(valueToRaiseMockException); + expect(state.noUserInteraction).not.toBeDefined(); + expect(state.error).toStrictEqual(new Error(valueToRaiseMockException)); + }); + it('should return initial state while unmounted', () => { + hook.unmount(); + const [state, copyToClipboard] = hook.result.current; + + act(() => copyToClipboard('value')); + expect(state.value).not.toBeDefined(); + expect(state.error).not.toBeDefined(); + expect(state.noUserInteraction).toBe(true); + }); }); From 86489d7f2bda3361b98c422b7a0993f28b6d8c79 Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 18:58:14 -0600 Subject: [PATCH 32/58] refactor: update use copy to clipboard --- src/useCopyToClipboard.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/useCopyToClipboard.ts b/src/useCopyToClipboard.ts index fc3b296a43..60f55b118c 100644 --- a/src/useCopyToClipboard.ts +++ b/src/useCopyToClipboard.ts @@ -18,31 +18,33 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = }); const copyToClipboard = useCallback(value => { + if (!isMounted()) { + return; + } + let noUserInteraction; try { - if (process.env.NODE_ENV === 'development') { - if (typeof value !== 'string') { - console.error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`); - } - } - - const noUserInteraction = writeText(value); - - if (!isMounted()) { + if (typeof value !== 'string' && typeof value !== 'number') { + const error = new Error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`); + if (process.env.NODE_ENV === 'development') console.error(error); + setState({ + value, + error, + noUserInteraction: true, + }); return; } + const normalizedValue = value.toString(); + noUserInteraction = writeText(normalizedValue); setState({ - value, + value: normalizedValue, error: undefined, noUserInteraction, }); } catch (error) { - if (!isMounted()) { - return; - } setState({ - value: undefined, + value, error, - noUserInteraction: true, + noUserInteraction, }); } }, []); From 6d5624eee2d66e439623bb77169431ecf652f548 Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 19:05:35 -0600 Subject: [PATCH 33/58] test: update case wording --- tests/useCopyToClipboard.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts index 84910b226c..39d467f765 100644 --- a/tests/useCopyToClipboard.test.ts +++ b/tests/useCopyToClipboard.test.ts @@ -36,7 +36,7 @@ describe('useCopyToClipboard', () => { expect(state.error).not.toBeDefined(); }); - it('should only call writeText if passed a valid input and set state', () => { + it('should not call writeText if passed an invalid input and set state', () => { const testValue = {}; // invalid value let [state, copyToClipboard] = hook.result.current; act(() => copyToClipboard(testValue)); @@ -58,6 +58,7 @@ describe('useCopyToClipboard', () => { expect(state.noUserInteraction).not.toBeDefined(); expect(state.error).toStrictEqual(new Error(valueToRaiseMockException)); }); + it('should return initial state while unmounted', () => { hook.unmount(); const [state, copyToClipboard] = hook.result.current; From d219ffa1ecd2912b32cdaeace8f8e1e0a871f068 Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 19:17:50 -0600 Subject: [PATCH 34/58] test: add dev logging test case and reset mocks --- tests/useCopyToClipboard.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts index 39d467f765..2ea2ead3c0 100644 --- a/tests/useCopyToClipboard.test.ts +++ b/tests/useCopyToClipboard.test.ts @@ -12,6 +12,7 @@ jest.mock('copy-to-clipboard', () => return true; }) ); +jest.spyOn(global.console, 'error').mockImplementation(() => {}); describe('useCopyToClipboard', () => { let hook; @@ -20,6 +21,10 @@ describe('useCopyToClipboard', () => { hook = renderHook(() => useCopyToClipboard()); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should be defined ', () => { expect(useCopyToClipboard).toBeDefined(); }); @@ -68,4 +73,22 @@ describe('useCopyToClipboard', () => { expect(state.error).not.toBeDefined(); expect(state.noUserInteraction).toBe(true); }); + + it('should console error if in dev environment', () => { + const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + const testValue = {}; // invalid value + + process.env.NODE_ENV = 'development'; + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + + [state, copyToClipboard] = hook.result.current; + + expect(writeText).not.toBeCalled(); + expect(console.error).toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).toBeDefined(); + }); }); From 100b86898f19b1b4d9cf0145580aa43fca36db36 Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 19:37:31 -0600 Subject: [PATCH 35/58] refactor: refuse empty strings --- src/useCopyToClipboard.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/useCopyToClipboard.ts b/src/useCopyToClipboard.ts index 60f55b118c..fb00c034e6 100644 --- a/src/useCopyToClipboard.ts +++ b/src/useCopyToClipboard.ts @@ -22,7 +22,9 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = return; } let noUserInteraction; + let normalizedValue; try { + // only strings and numbers casted to strings can be copied to clipboard if (typeof value !== 'string' && typeof value !== 'number') { const error = new Error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`); if (process.env.NODE_ENV === 'development') console.error(error); @@ -33,7 +35,18 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = }); return; } - const normalizedValue = value.toString(); + // empty strings are also considered invalid + else if (value === '') { + const error = new Error(`Cannot copy empty string to clipboard.`); + if (process.env.NODE_ENV === 'development') console.error(error); + setState({ + value, + error, + noUserInteraction: true, + }); + return; + } + normalizedValue = value.toString(); noUserInteraction = writeText(normalizedValue); setState({ value: normalizedValue, @@ -42,7 +55,7 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = }); } catch (error) { setState({ - value, + value: normalizedValue, error, noUserInteraction, }); From aaefdf85356450b88f9719903a4431535f6e4ace Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 19:41:43 -0600 Subject: [PATCH 36/58] test: update test to include empty string as invalid --- tests/useCopyToClipboard.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts index 2ea2ead3c0..4f73f8e099 100644 --- a/tests/useCopyToClipboard.test.ts +++ b/tests/useCopyToClipboard.test.ts @@ -42,7 +42,7 @@ describe('useCopyToClipboard', () => { }); it('should not call writeText if passed an invalid input and set state', () => { - const testValue = {}; // invalid value + let testValue = {}; // invalid value let [state, copyToClipboard] = hook.result.current; act(() => copyToClipboard(testValue)); [state, copyToClipboard] = hook.result.current; @@ -51,6 +51,15 @@ describe('useCopyToClipboard', () => { expect(state.value).toBe(testValue); expect(state.noUserInteraction).toBe(true); expect(state.error).toBeDefined(); + + testValue = ''; // emtpy string is also invalid + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + console.log(state); + expect(writeText).not.toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).toBeDefined(); }); it('should catch exception thrown by copy-to-clipboard and set state', () => { From f17c8a0f8e63bfddb8f13a094edbea1e3ee9680b Mon Sep 17 00:00:00 2001 From: Jaime Liz Date: Thu, 16 Jan 2020 19:43:35 -0600 Subject: [PATCH 37/58] =?UTF-8?q?fix:=20remove=20console=20log=20?= =?UTF-8?q?=F0=9F=A4=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/useCopyToClipboard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts index 4f73f8e099..fd8de8ebb4 100644 --- a/tests/useCopyToClipboard.test.ts +++ b/tests/useCopyToClipboard.test.ts @@ -55,7 +55,7 @@ describe('useCopyToClipboard', () => { testValue = ''; // emtpy string is also invalid act(() => copyToClipboard(testValue)); [state, copyToClipboard] = hook.result.current; - console.log(state); + expect(writeText).not.toBeCalled(); expect(state.value).toBe(testValue); expect(state.noUserInteraction).toBe(true); From 3cf4f44d6286560aecc3c8decc2e8570cf531005 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 17 Jan 2020 09:25:26 +0100 Subject: [PATCH 38/58] =?UTF-8?q?style:=20=F0=9F=92=84=20run=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useCustomCompareEffect.ts | 5 +---- src/useUpdate.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/useCustomCompareEffect.ts b/src/useCustomCompareEffect.ts index 28cd635044..b5c7314292 100644 --- a/src/useCustomCompareEffect.ts +++ b/src/useCustomCompareEffect.ts @@ -2,10 +2,7 @@ import { DependencyList, EffectCallback, useEffect, useRef } from 'react'; const isPrimitive = (val: any) => val !== Object(val); -type DepsEqualFnType = ( - prevDeps: TDeps, - nextDeps: TDeps -) => boolean; +type DepsEqualFnType = (prevDeps: TDeps, nextDeps: TDeps) => boolean; const useCustomCompareEffect = ( effect: EffectCallback, diff --git a/src/useUpdate.ts b/src/useUpdate.ts index 8a50dcb2f6..bcc4511ae3 100644 --- a/src/useUpdate.ts +++ b/src/useUpdate.ts @@ -4,7 +4,7 @@ const updateReducer = (num: number): number => (num + 1) % 1_000_000; const useUpdate = () => { const [, update] = useReducer(updateReducer, 0); - return update as (() => void); + return update as () => void; }; export default useUpdate; From 3a8ffef1a1bcb87b1a832ea161e06be3e7c13a70 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 17 Jan 2020 09:35:37 +0100 Subject: [PATCH 39/58] Release 14.0.0-alpha.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d9082bf4e3..d589e44ce7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.0.0-alpha.1", + "version": "14.0.0-alpha.2", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 062e60663d5677fe0ad98c23c82f6c0086423da2 Mon Sep 17 00:00:00 2001 From: carlos Date: Thu, 30 Jan 2020 18:03:54 +0800 Subject: [PATCH 40/58] Resolve #934: useAsyncFn: keeping the previous state when start running the async function --- docs/useAsyncFn.md | 2 +- src/useAsyncFn.ts | 7 ++++++- tests/useAsyncFn.test.tsx | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/useAsyncFn.md b/docs/useAsyncFn.md index cc4825fb5a..94cc243ece 100644 --- a/docs/useAsyncFn.md +++ b/docs/useAsyncFn.md @@ -32,5 +32,5 @@ const Demo = ({url}) => { ## Reference ```ts -useAsyncFn(fn, deps?: any[]); +useAsyncFn(fn, deps?: any[], initialState?: AsyncState); ``` diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index 0b0cd74875..31ad4f72e6 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -7,6 +7,11 @@ export type AsyncState = error?: undefined; value?: undefined; } + | { + loading: true; + error?: Error | undefined; + value?: T; + } | { loading: false; error: Error; @@ -35,7 +40,7 @@ export default function useAsyncFn( const callback = useCallback((...args: Args | []) => { const callId = ++lastCallId.current; - set({ loading: true }); + set(prevState => ({ ...prevState, loading: true })); return fn(...args).then( value => { diff --git a/tests/useAsyncFn.test.tsx b/tests/useAsyncFn.test.tsx index 63ca25b2df..4dcebfc326 100644 --- a/tests/useAsyncFn.test.tsx +++ b/tests/useAsyncFn.test.tsx @@ -126,4 +126,31 @@ describe('useAsyncFn', () => { await hook.waitForNextUpdate(); expect(hook.result.current[0]).toEqual({ loading: false, value: 2 }); }); + + it('should keeping value of initialState when loading', async () => { + const fetch = async () => 'new state'; + const initialState = { loading: false, value: 'init state' }; + + const hook = renderHook<{ fn: () => Promise }, [AsyncState, () => Promise]>( + ({ fn }) => useAsyncFn(fn, [fn], initialState), + { + initialProps: { fn: fetch }, + } + ); + + const [state, callback] = hook.result.current; + expect(state.loading).toBe(false); + expect(state.value).toBe('init state'); + + act(() => { + callback(); + }); + + expect(hook.result.current[0].loading).toBe(true); + expect(hook.result.current[0].value).toBe('init state'); + + await hook.waitForNextUpdate(); + expect(hook.result.current[0].loading).toBe(false); + expect(hook.result.current[0].value).toBe('new state'); + }); }); From becdfff19433b6e026645545d10681471dd6d573 Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Sun, 2 Feb 2020 21:21:34 -0800 Subject: [PATCH 41/58] add serializer options back to useLocalStorage --- src/useLocalStorage.ts | 17 ++++++++--------- tests/useLocalStorage.test.ts | 34 +++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index e92b530ac9..5378784d97 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -17,7 +17,6 @@ const useLocalStorage = ( initialValue?: T, options?: parserOptions ): [T, Dispatch>] => { - // TODO: !localStorage needed? What does isClient do? if (!isClient || !localStorage) { return [initialValue as T, () => {}]; } @@ -25,9 +24,8 @@ const useLocalStorage = ( throw new Error('useLocalStorage key may not be nullish or undefined'); } - // Use provided serializer/deserializer or the default ones - const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify; - const deserializer = options ? (options.raw ? String : options.deserializer || null) : JSON.parse; + // @ts-ignore - These are allowed to be undefined + const { raw, deserializer, serializer } = options || {}; let localStorageValue: string | null = null; try { @@ -40,20 +38,21 @@ const useLocalStorage = ( const state: T = useMemo(() => { try { /* If key hasn't been set yet */ - console.log({ localStorageValue, initialValue, deserializer }); if (localStorageValue === null) return initialValue as T; - return deserializer ? deserializer(localStorageValue) : localStorageValue; + if (raw) return localStorageValue; + if (!raw && deserializer) return deserializer(localStorageValue); + return JSON.parse(localStorageValue); } catch { /* JSON.parse and JSON.stringify can throw. */ return localStorageValue === null ? initialValue : localStorageValue; } - }, [key, localStorageValue]); + }, [key, localStorageValue, raw, deserializer]); const setState: Dispatch> = useCallback( (valOrFunc: SetStateAction): void => { try { let newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc; - newState = typeof newState === 'string' ? newState : serializer(newState); + newState = typeof newState === 'string' ? newState : (serializer || JSON.stringify)(newState); localStorage.setItem(key, newState); } catch { /** @@ -62,7 +61,7 @@ const useLocalStorage = ( */ } }, - [state, serializer] + [state, raw, serializer] ); /* If value hasn't been set yet (null not 'null') then initialize it. */ diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index e27da12197..421e823d7f 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -58,6 +58,10 @@ 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")); @@ -150,6 +154,21 @@ 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", () => { @@ -183,20 +202,15 @@ describe(useLocalStorage, () => { describe("Options: raw", () => { const STRINGIFIED_VALUE = '{"a":"b"}'; - const serializer = (_: string) => '321'; - const deserializer = (_: string) => '123'; - - const rawOption = { raw: true, serializer, deserializer }; - it("returns a string when localStorage is a stringified object", () => { localStorage.setItem("foo", JSON.stringify({ fizz: "buzz" })); - const { result } = renderHook(() => useLocalStorage("foo", null, rawOption)); + 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, rawOption)); + const { result, rerender } = renderHook(() => useLocalStorage("foo", null, { raw: true })); const [, setFoo] = result.current; // @ts-ignore @@ -210,10 +224,6 @@ describe(useLocalStorage, () => { // @ts-ignore 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 })); @@ -236,7 +246,6 @@ describe(useLocalStorage, () => { expect(result.current[0]).toEqual(STRINGIFIED_VALUE); }); - it("should return initialValue if localStorage empty and set that to localStorage", () => { const key = "some_key"; @@ -249,7 +258,6 @@ describe(useLocalStorage, () => { 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); From 8d18ef949fe51a3abb6577c916d44512808f1a1e Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Sun, 2 Feb 2020 21:23:27 -0800 Subject: [PATCH 42/58] linting --- tests/useLocalStorage.test.ts | 200 +++++++++++++++++----------------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 421e823d7f..ff1f623474 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -1,6 +1,6 @@ -import useLocalStorage from "../src/useLocalStorage"; -import "jest-localstorage-mock"; -import { renderHook, act } from "@testing-library/react-hooks"; +import useLocalStorage from '../src/useLocalStorage'; +import 'jest-localstorage-mock'; +import { renderHook, act } from '@testing-library/react-hooks'; describe(useLocalStorage, () => { afterEach(() => { @@ -8,47 +8,47 @@ describe(useLocalStorage, () => { jest.clearAllMocks(); }); - it("retrieves an existing value from localStorage", () => { - localStorage.setItem("foo", "bar"); - const { result } = renderHook(() => useLocalStorage("foo")); + it('retrieves an existing value from localStorage', () => { + localStorage.setItem('foo', 'bar'); + const { result } = renderHook(() => useLocalStorage('foo')); const [state] = result.current; - expect(state).toEqual("bar"); + expect(state).toEqual('bar'); }); - it("should return initialValue if localStorage empty and set that to localStorage", () => { - const { result } = renderHook(() => useLocalStorage("foo", "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(state).toEqual('bar'); + expect(localStorage.__STORE__.foo).toEqual('bar'); }); - it("prefers existing value over initial state", () => { - localStorage.setItem("foo", "bar"); - const { result } = renderHook(() => useLocalStorage("foo", "baz")); + it('prefers existing value over initial state', () => { + localStorage.setItem('foo', 'bar'); + const { result } = renderHook(() => useLocalStorage('foo', 'baz')); const [state] = result.current; - expect(state).toEqual("bar"); + expect(state).toEqual('bar'); }); - it("does not clobber existing localStorage with initialState", () => { - localStorage.setItem("foo", "bar"); - const { result } = renderHook(() => useLocalStorage("foo", "buzz")); + it('does not clobber existing localStorage with initialState', () => { + 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")); + it('correctly updates localStorage', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar')); const [, setFoo] = result.current; - act(() => setFoo("baz")); + 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")); + 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")); + it('returns and allow setting null', () => { + localStorage.setItem('foo', 'null'); + const { result, rerender } = renderHook(() => useLocalStorage('foo')); const [foo1, setFoo] = result.current; act(() => setFoo(null)); @@ -62,34 +62,34 @@ describe(useLocalStorage, () => { 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")); + it('correctly and promptly returns a new value', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar')); const [, setFoo] = result.current; - act(() => setFoo("baz")); + act(() => setFoo('baz')); rerender(); const [foo] = result.current; - expect(foo).toEqual("baz"); + expect(foo).toEqual('baz'); }); - it("should not double-JSON-stringify stringy values", () => { - const { result, rerender } = renderHook(() => useLocalStorage("foo", "bar")); + it('should not double-JSON-stringify stringy values', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar')); const [, setFoo] = result.current; - act(() => setFoo(JSON.stringify("baz"))); + act(() => setFoo(JSON.stringify('baz'))); rerender(); const [foo] = result.current; expect(foo).not.toMatch(/\\/i); // should not contain extra escapes - expect(foo).toBe("baz"); + 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")); - const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage("foo")); + it('keeps multiple hooks accessing the same key in sync', () => { + localStorage.setItem('foo', 'bar'); + const { result: r1, rerender: rerender1 } = renderHook(() => useLocalStorage('foo')); + const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage('foo')); const [, setFoo] = r1.current; - act(() => setFoo("potato")); + act(() => setFoo('potato')); rerender1(); rerender2(); @@ -97,59 +97,59 @@ describe(useLocalStorage, () => { const [val2] = r2.current; expect(val1).toEqual(val2); - expect(val1).toEqual("potato"); - expect(val2).toEqual("potato"); + 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")); + 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); }); - it("safely initializes objects to localStorage", () => { - const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>("foo", { ok: 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); }); - it("safely sets objects to localStorage", () => { - const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>("foo", { ok: true })); + it('safely sets objects to localStorage', () => { + const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true })); const [, setFoo] = result.current; - act(() => setFoo({ ok: "bar" })); + act(() => setFoo({ ok: 'bar' })); 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 })); + it('safely returns objects from updates', () => { + const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true })); const [, setFoo] = result.current; - act(() => setFoo({ ok: "bar" })); + act(() => setFoo({ ok: 'bar' })); rerender(); 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", () => { + it('sets localStorage from the function updater', () => { const { result, rerender } = renderHook(() => - useLocalStorage<{ foo: string; fizz?: string }>("foo", { foo: "bar" }) + 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", () => { + it('rejects nullish or undefined keys', () => { const { result } = renderHook(() => useLocalStorage(null as any)); try { result.current; - fail("hook should have thrown"); + fail('hook should have thrown'); } catch (e) { expect(String(e)).toMatch(/key may not be/i); } @@ -170,9 +170,9 @@ describe(useLocalStorage, () => { 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", () => { - const { result, rerender } = renderHook(() => useLocalStorage("foo", { ok: true })); + describe('eslint react-hooks/rules-of-hooks', () => { + it('memoizes an object between rerenders', () => { + const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true })); result.current; // if localStorage isn't set then r1 and r2 will be different rerender(); @@ -181,18 +181,18 @@ 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 })); + 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 })); const [r1] = result.current; // if localStorage isn't set then r1 and r2 will be different rerender(); 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 })); + it('memoizes the setState function', () => { + localStorage.setItem('foo', JSON.stringify({ ok: true })); + const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true })); const [, s1] = result.current; rerender(); const [, s2] = result.current; @@ -200,54 +200,54 @@ describe(useLocalStorage, () => { }); }); - describe("Options: raw", () => { + 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 })); + 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"); + 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 })); + 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' })); rerender(); const [foo] = result.current; - expect(typeof foo).toBe("string"); + 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).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 })); + 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' })); rerender(); const [value] = result.current; // @ts-ignore - expect(JSON.parse(value).fizz).toEqual("bang"); + expect(JSON.parse(value).fizz).toEqual('bang'); }); - describe("raw true", () => { - it("should set the value from existing localStorage key", () => { - const key = "some_key"; + 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 })); + 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"; + 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 })); @@ -255,18 +255,18 @@ describe(useLocalStorage, () => { 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"; + 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(result.current[0]).toBe('123'); }); }); }); From e5f41678f8d62714d4aed45b4fc839693af5d98a Mon Sep 17 00:00:00 2001 From: Tyler Swavely Date: Sun, 2 Feb 2020 21:26:06 -0800 Subject: [PATCH 43/58] remove unused file --- tests/setupTests.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/setupTests.ts diff --git a/tests/setupTests.ts b/tests/setupTests.ts deleted file mode 100644 index 1c787210fd..0000000000 --- a/tests/setupTests.ts +++ /dev/null @@ -1 +0,0 @@ -import 'jest-localstorage-mock'; From 68fb835ea64cf5587c99645a09c6de93ab1b71df Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 4 Feb 2020 00:38:56 +0100 Subject: [PATCH 44/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20better=20serializat?= =?UTF-8?q?ion=20handling=20in=20useLocalStorage=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- src/useLocalStorage.ts | 78 +++++++++++----------- tests/useLocalStorage.test.ts | 121 ++++++++++++---------------------- 3 files changed, 83 insertions(+), 119 deletions(-) diff --git a/package.json b/package.json index 977a981819..d4d196dc23 100644 --- a/package.json +++ b/package.json @@ -163,8 +163,7 @@ "/tests/**/*.test.(ts|tsx)" ], "setupFiles": [ - "/tests/_setup.js", - "./tests/setupTests.ts" + "/tests/_setup.js" ] } } diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 14e6b85bca..d843417c5d 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -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 = | { @@ -19,49 +18,57 @@ const useLocalStorage = ( initialValue?: T, options?: parserOptions ): [T | undefined, Dispatch>, () => 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(() => { 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> = useCallback( - (valOrFunc: SetStateAction): void => { + const set: Dispatch> = 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(() => { @@ -74,12 +81,7 @@ const useLocalStorage = ( } }, [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; diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index ff1f623474..696aeb8cfc 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -9,29 +9,33 @@ 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')); @@ -39,13 +43,15 @@ describe(useLocalStorage, () => { 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')); @@ -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')); @@ -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')); @@ -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 })); @@ -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 })); @@ -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 { @@ -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', () => { @@ -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 })); @@ -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 })); @@ -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'); }); }); }); From d7117a988b6b772c6882393ef111d69ca4730cdc Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 4 Feb 2020 01:39:20 +0100 Subject: [PATCH 45/58] Release 14.0.0-alpha.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1879e0a218..169a2e40ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.0.0-alpha.2", + "version": "14.0.0-alpha.3", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 3c5f1fb1f135480e4b356c7d8e2f1a99ae56addf Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Feb 2020 11:21:10 +0100 Subject: [PATCH 46/58] =?UTF-8?q?style:=20=F0=9F=92=84=20disable=20ESLint?= =?UTF-8?q?=20in=20couple=20of=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useScratch.ts | 1 + tests/useLocalStorage.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/useScratch.ts b/src/useScratch.ts index 35fafc65cd..4b71ccb601 100644 --- a/src/useScratch.ts +++ b/src/useScratch.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ import { useState, useEffect, useRef, FC, cloneElement } from 'react'; import { render } from 'react-universal-interface'; diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 696aeb8cfc..da01dc8698 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ import useLocalStorage from '../src/useLocalStorage'; import 'jest-localstorage-mock'; import { renderHook, act } from '@testing-library/react-hooks'; From 9f608f59ce4965b435b6d63662aaeffa810281ae Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Feb 2020 12:34:24 +0100 Subject: [PATCH 47/58] =?UTF-8?q?chore:=20=F0=9F=A4=96=20add=20all=20contr?= =?UTF-8?q?ibutors=20to=20GitHub=20heart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/FUNDING.yml | 99 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0b76ccb7b3..dbfac2729f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,100 @@ # These are supported funding model platforms -github: streamich +github: [ + "streamich", + "wardoost", + "xobotyi", + "Belco90", + "ankithkonda", + "ayush987goyal", + "NullVoxPopuli", + "lintuming", + "Granipouss", + "ythecombinator", + "james2406", + "jakapatb", + "MrHuangJser", + "zaguiini", + "ppeeou", + "liuyuchenzh", + "brickspert", + "artywhite", + "PetterIve", + "realdennis", + "lvl99", + "gelove", + "KusStar", + "xiaoxiangmoe", + "nmccready", + "mattleonowicz", + "kevinnorris", + "dubzzz", + "dependabot[bot]", + "ShizukuIchi", + "ManojBahuguna", + "Jivings", + "Dosant", + "zsh2401", + "xiaoboost", + "revskill10", + "mtinner", + "monkeywithacupcake", + "mitchheddles", + "maxzitron", + "macinjoke", + "jeetiss", + "ilyalesik", + "hijiangtao", + "f", + "elliottsj", + "droganov", + "denysdovhan", + "dabuside", + "benneq", + "azukaar", + "ariesjia", + "andrico1234", + "adesurirey", + "OBe95", + "FredyC", + "Cretezy", + "zyy7259", + "zslabs", + "vinitsood", + "uxitten", + "thevtm", + "tanem", + "suyingtao", + "srph", + "rkostrzewski", + "qianL93", + "o-alexandrov", + "nucleartux", + "natew", + "maxmalov", + "liaoyinglong", + "koenvanzuijlen", + "josmardias", + "jeemyeong", + "jazzqi", + "jakyle", + "jakeboone02", + "inker", + "glarivie", + "garrettmaring", + "dovidweisz", + "daniel-hauser", + "d-asensio", + "charlax", + "TylerR909", + "Rogdham", + "OctoD", + "MajorBreakfast", + "Jfelix61", + "Flydiverny", + "FlickerLogicalStack", + "DmacMcgreg", + "Dattaya", + "Andrey-Bazhanov", + "AlvaroBernalG" +] From 3343042eef6bbb39db3dd77ab4300c0f824ceddc Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Feb 2020 14:52:14 +0100 Subject: [PATCH 48/58] Release 14.0.0-alpha.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ff0a99482..71eaea04ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.0.0-alpha.3", + "version": "14.0.0-alpha.4", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 2daf76990d0e1040f8c0f31e16e7c1eebd94c9bf Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Feb 2020 22:42:07 +0100 Subject: [PATCH 49/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20make=20useMeasure?= =?UTF-8?q?=20work=20on=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useMeasure.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 57d5c6d3fb..f8d42766d7 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; +import { isClient } from './util'; export type UseMeasureRect = Pick< DOMRectReadOnly, @@ -47,4 +48,4 @@ const useMeasure = (): UseMeasureResult => { const useMeasureMock = () => [() => {}, defaultState]; -export default !!(window as any).ResizeObserver ? useMeasure : useMeasureMock; +export default (isClient && !!(window as any).ResizeObserver) ? useMeasure : useMeasureMock; From 3fa25177e9836daa626485502702e097f4ee2fde Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Feb 2020 22:43:39 +0100 Subject: [PATCH 50/58] Release 14.0.0-alpha.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fe90490a26..87d0f7bd50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.0.0-alpha.4", + "version": "14.0.0-alpha.5", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From a1648439021a45c781c2074489d7c6aaaa867406 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:11:35 +0100 Subject: [PATCH 51/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20improve=20implemen?= =?UTF-8?q?tation=20of=20useMeasure()=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: useMeasure() now defaults all values to -1, if they were not set and internal implementation heavily refactored. --- src/useMeasure.ts | 61 +++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 4622355f6a..29ac5ad8db 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,39 +1,48 @@ -import { useCallback, useState } from 'react'; +import { useState, useMemo } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; +import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; -export type ContentRect = Pick; +export type UseMeasureRect = Pick< + DOMRectReadOnly, + 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' +>; +export type UseMeasureRef = (element: HTMLElement) => void; +export type UseMeasureResult = [UseMeasureRef, UseMeasureRect]; -const useMeasure = (): [(instance: T) => void, ContentRect] => { - const [rect, set] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); +const defaultState: UseMeasureRect = { + x: -1, + y: -1, + width: -1, + height: -1, + top: -1, + left: -1, + bottom: -1, + right: -1, +}; + +const useMeasure = (): UseMeasureResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(defaultState); - const [observer] = useState( + const observer = useMemo( () => new ResizeObserver(entries => { - const entry = entries[0]; - if (entry) { - set(entry.contentRect); + if (entries[0]) { + const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; + setRect({ x, y, width, height, top, left, bottom, right }); } - }) + }), + [] ); - const ref = useCallback( - node => { + useIsomorphicLayoutEffect(() => { + if (!element) return; + observer.observe(element); + return () => { observer.disconnect(); - if (node) { - observer.observe(node); - } - }, - [observer] - ); + }; + }, [element]); + return [ref, rect]; }; From 866f3d740b08d4772dfbad9c48b3b0b8bac69a28 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:14:26 +0100 Subject: [PATCH 52/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20mock=20useMeasure(?= =?UTF-8?q?)=20hook=20on=20server=20and=20w/o=20ResizeObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useMeasure.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 29ac5ad8db..37af79aae8 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -46,4 +46,6 @@ const useMeasure = (): UseMeasureResult => { return [ref, rect]; }; -export default useMeasure; +const useMeasureMock = () => [() => {}, defaultState]; + +export default !!(window as any).ResizeObserver ? useMeasure : useMeasureMock; From 3305e1567ce93d2d9414690d2f3b22b9442fa94d Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:17:21 +0100 Subject: [PATCH 53/58] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20mention=20Re?= =?UTF-8?q?sizeObserver=20polyfill=20in=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/useMeasure.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/useMeasure.md b/docs/useMeasure.md index d8e87a0cad..306fec3b6a 100644 --- a/docs/useMeasure.md +++ b/docs/useMeasure.md @@ -25,6 +25,21 @@ const Demo = () => { }; ``` +This hook uses [`ResizeObserver` API][resize-observer], if you want to support +legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] +before running your app. + +```js +if (!window.ResizeObserver) { + window.ResizeObserver = (await import('resize-observer-polyfill')).default; +} +``` + + ## Related hooks - [useSize](./useSize.md) + + +[resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver +[resize-observer-polyfill]: https://www.npmjs.com/package/resize-observer-polyfill From 2a13fbae45af3a26c984de03130139181c0c3839 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 12 Jan 2020 17:48:07 +0100 Subject: [PATCH 54/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20remove=20resize-ob?= =?UTF-8?q?server-polyfill=20from=20useMeasure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: resize-observer-polyfill package is not used with useMeasure() hook anymore. --- src/useMeasure.ts | 3 +- tests/setupTests.ts | 6 + tests/useMeasure.test.ts | 240 ++++++++++++++++++++++++++------------- 3 files changed, 169 insertions(+), 80 deletions(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 37af79aae8..57d5c6d3fb 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,5 +1,4 @@ import { useState, useMemo } from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; export type UseMeasureRect = Pick< @@ -26,7 +25,7 @@ const useMeasure = (): UseMeasureResult => { const observer = useMemo( () => - new ResizeObserver(entries => { + new (window as any).ResizeObserver(entries => { if (entries[0]) { const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; setRect({ x, y, width, height, top, left, bottom, right }); diff --git a/tests/setupTests.ts b/tests/setupTests.ts index 1c787210fd..841e385b69 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -1 +1,7 @@ import 'jest-localstorage-mock'; + +(window as any).ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + disconnect() {} +}; diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index a242865bbe..33c6f805d4 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -1,89 +1,79 @@ -/* eslint-disable */ -import { act, renderHook } from '@testing-library/react-hooks'; -import useMeasure, { ContentRect } from '../src/useMeasure'; - -interface Entry { - target: HTMLElement; - contentRect: ContentRect; -} - -jest.mock('resize-observer-polyfill', () => { - return class ResizeObserver { - private cb: (entries: Entry[]) => void; - private map: WeakMap; - private targets: HTMLElement[]; - constructor(cb: () => void) { - this.cb = cb; - this.map = new WeakMap(); - this.targets = []; - } - public disconnect() { - this.targets.map(target => { - const originMethod = this.map.get(target); - target.setAttribute = originMethod; - this.map.delete(target); - }); - } - public observe(target: HTMLElement) { - const method = 'setAttribute'; - const originMethod = target[method]; - this.map.set(target, originMethod); - this.targets.push(target); - target[method] = (...args) => { - const [attrName, value] = args; - if (attrName === 'style') { - const rect: DOMRectReadOnly = { - x: 0, - y: 0, - top: 0, - left: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - } as DOMRectReadOnly; - value.split(';').map(kv => { - const [key, v] = kv.split(':'); - if (['top', 'bottom', 'left', 'right', 'width', 'height'].includes(key)) { - rect[key] = parseInt(v, 10); - } - }); - target.getBoundingClientRect = () => rect; - } - originMethod.apply(target, args); - this.fireCallback(); - }; - } - private fireCallback() { - if (this.cb) { - this.cb( - this.targets.map(target => { - return { - target, - contentRect: target.getBoundingClientRect() as ContentRect, - }; - }) - ); - } +import { renderHook, act } from '@testing-library/react-hooks'; +import useMeasure, { UseMeasureRef } from '../src/useMeasure'; + +it('by default, state defaults every value to -1', () => { + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(result.current[1]).toMatchObject({ + width: -1, + height: -1, + top: -1, + bottom: -1, + left: -1, + right: -1, + }); +}); + +it('synchronously sets up ResizeObserver listener', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; } + observe() {} + disconnect() {} }; + + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(typeof listener).toBe('function'); }); -it('reacts to changes in size of any of the observed elements', () => { +it('tracks rectangle of a DOM element', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + observe() {} + disconnect() {} + }; + const { result } = renderHook(() => useMeasure()); - const div = document.createElement('div'); - result.current[0](div); - expect(result.current[1]).toMatchObject({ - width: 0, - height: 0, - top: 0, - bottom: 0, - left: 0, - right: 0, + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + act(() => { + listener!([{ + contentRect: { + x: 1, + y: 2, + width: 200, + height: 200, + top: 100, + bottom: 0, + left: 100, + right: 0, + } + }]); }); - act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100')); expect(result.current[1]).toMatchObject({ + x: 1, + y: 2, width: 200, height: 200, top: 100, @@ -92,3 +82,97 @@ it('reacts to changes in size of any of the observed elements', () => { right: 0, }); }); + +it('tracks multiple updates', () => { + let listener: ((rect: any) => void) | undefined = undefined; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + observe() {} + disconnect() {} + }; + + const { result } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + act(() => { + listener!([{ + contentRect: { + x: 1, + y: 1, + width: 1, + height: 1, + top: 1, + bottom: 1, + left: 1, + right: 1, + } + }]); + }); + + expect(result.current[1]).toMatchObject({ + x: 1, + y: 1, + width: 1, + height: 1, + top: 1, + bottom: 1, + left: 1, + right: 1, + }); + + act(() => { + listener!([{ + contentRect: { + x: 2, + y: 2, + width: 2, + height: 2, + top: 2, + bottom: 2, + left: 2, + right: 2, + } + }]); + }); + + expect(result.current[1]).toMatchObject({ + x: 2, + y: 2, + width: 2, + height: 2, + top: 2, + bottom: 2, + left: 2, + right: 2, + }); +}); + +it('calls .disconnect() on ResizeObserver when component unmounts', () => { + const disconnect = jest.fn(); + (window as any).ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + disconnect() { + disconnect(); + } + }; + + const { result, unmount } = renderHook(() => useMeasure()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + + expect(disconnect).toHaveBeenCalledTimes(0); + + unmount(); + + expect(disconnect).toHaveBeenCalledTimes(1); +}); From d06688b26e24d381ed40afae2ad10a5e5765e29f Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 16 May 2020 14:49:13 +0200 Subject: [PATCH 55/58] =?UTF-8?q?style:=20=F0=9F=92=84=20remove=20unused?= =?UTF-8?q?=20constructors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/setupTests.ts | 1 - tests/useMeasure.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/setupTests.ts b/tests/setupTests.ts index 841e385b69..c0ced93c0c 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -1,7 +1,6 @@ import 'jest-localstorage-mock'; (window as any).ResizeObserver = class ResizeObserver { - constructor() {} observe() {} disconnect() {} }; diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index 33c6f805d4..a008e61c68 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -156,7 +156,6 @@ it('tracks multiple updates', () => { it('calls .disconnect() on ResizeObserver when component unmounts', () => { const disconnect = jest.fn(); (window as any).ResizeObserver = class ResizeObserver { - constructor() {} observe() {} disconnect() { disconnect(); From dc92b646d0cd0f12868fde370c83e94ca3c7e297 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 16 May 2020 14:53:10 +0200 Subject: [PATCH 56/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20revert=20useMeasure?= =?UTF-8?q?=20defaults=20to=20zeros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useMeasure.ts | 16 ++++++++-------- tests/useMeasure.test.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 57d5c6d3fb..a62d2bbcbc 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -9,14 +9,14 @@ export type UseMeasureRef = (element: HTMLElement) => void; export type UseMeasureResult = [UseMeasureRef, UseMeasureRect]; const defaultState: UseMeasureRect = { - x: -1, - y: -1, - width: -1, - height: -1, - top: -1, - left: -1, - bottom: -1, - right: -1, + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, }; const useMeasure = (): UseMeasureResult => { diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index a008e61c68..94c83b6e7c 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -10,12 +10,12 @@ it('by default, state defaults every value to -1', () => { }); expect(result.current[1]).toMatchObject({ - width: -1, - height: -1, - top: -1, - bottom: -1, - left: -1, - right: -1, + width: 0, + height: 0, + top: 0, + bottom: 0, + left: 0, + right: 0, }); }); From 9ae494fc1874619aad2f3856df790cbc1a2a8239 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 16 May 2020 14:53:56 +0200 Subject: [PATCH 57/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20correctly=20test=20?= =?UTF-8?q?if=20env=20is=20browser=20in=20useMeasure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useMeasure.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/useMeasure.ts b/src/useMeasure.ts index a62d2bbcbc..a5bee4aa4f 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; +import { isClient } from './util'; export type UseMeasureRect = Pick< DOMRectReadOnly, @@ -47,4 +48,4 @@ const useMeasure = (): UseMeasureResult => { const useMeasureMock = () => [() => {}, defaultState]; -export default !!(window as any).ResizeObserver ? useMeasure : useMeasureMock; +export default (isClient && !!(window as any).ResizeObserver) ? useMeasure : useMeasureMock; From 8e6ec929fa90df7cfb3dd032e8626a23d6f7f95c Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 16 May 2020 15:06:47 +0200 Subject: [PATCH 58/58] Release 15.0.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index af63bb2c59..a24a734e46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "15.0.0-alpha.0", + "version": "15.0.0-alpha.1", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js",