diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0b76ccb7..dbfac272 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" +] diff --git a/docs/useAsyncFn.md b/docs/useAsyncFn.md index cc4825fb..94cc243e 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/docs/useLocalStorage.md b/docs/useLocalStorage.md index 00574bf1..a00c68a6 100644 --- a/docs/useLocalStorage.md +++ b/docs/useLocalStorage.md @@ -8,13 +8,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 (
Value: {value}
+
); }; diff --git a/docs/useMeasure.md b/docs/useMeasure.md index d8e87a0c..306fec3b 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 diff --git a/package.json b/package.json index 7971255e..a24a734e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "14.3.0", + "version": "15.0.0-alpha.1", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", @@ -169,7 +169,7 @@ "/tests/**/*.test.(ts|tsx)" ], "setupFiles": [ - "./tests/setupTests.ts" + "/tests/setupTests.ts" ] } } diff --git a/src/useAsync.ts b/src/useAsync.ts index e20b0b68..887326d9 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -1,13 +1,11 @@ import { DependencyList, useEffect } from 'react'; import useAsyncFn from './useAsyncFn'; +import { FnReturningPromise } from './util'; -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 cd931e5c..0ec39a52 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import { DependencyList, useCallback, useState, useRef } from 'react'; import useMountedState from './useMountedState'; +import { FnReturningPromise, PromiseType } from './util'; export type AsyncState = | { @@ -8,6 +9,11 @@ export type AsyncState = error?: undefined; value?: undefined; } + | { + loading: true; + error?: Error | undefined; + value?: T; + } | { loading: false; error: Error; @@ -19,24 +25,22 @@ export type AsyncState = value: T; }; -export type AsyncFn = [ - AsyncState, - (...args: Args | []) => Promise -]; +type StateFromFnReturningPromise = AsyncState>>; -export default function useAsyncFn( - fn: (...args: Args | []) => Promise, +export type AsyncFnReturn = [StateFromFnReturningPromise, T]; + +export default function useAsyncFn( + fn: T, deps: DependencyList = [], - initialState: AsyncState = { loading: false } -): AsyncFn { + initialState: StateFromFnReturningPromise = { loading: false } +): AsyncFnReturn { const lastCallId = useRef(0); - const [state, set] = useState>(initialState); - const isMounted = useMountedState(); + const [state, set] = useState>(initialState); - const callback = useCallback((...args: Args | []) => { + const callback = useCallback((...args: Parameters): ReturnType => { const callId = ++lastCallId.current; - set({ loading: true }); + set(prevState => ({ ...prevState, loading: true })); return fn(...args).then( value => { @@ -49,8 +53,8 @@ export default function useAsyncFn( return error; } - ); + ) as ReturnType; }, deps); - return [state, callback]; + return [state, (callback as unknown) as T]; } diff --git a/src/useCopyToClipboard.ts b/src/useCopyToClipboard.ts index ca29f714..e39f9c6d 100644 --- a/src/useCopyToClipboard.ts +++ b/src/useCopyToClipboard.ts @@ -19,31 +19,46 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = }); const copyToClipboard = useCallback(value => { + if (!isMounted()) { + return; + } + let noUserInteraction; + let normalizedValue; 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()) { + // 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); + setState({ + value, + error, + noUserInteraction: true, + }); return; } + // 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, + value: normalizedValue, error: undefined, noUserInteraction, }); } catch (error) { - if (!isMounted()) { - return; - } setState({ - value: undefined, + value: normalizedValue, error, - noUserInteraction: true, + noUserInteraction, }); } }, []); diff --git a/src/useCustomCompareEffect.ts b/src/useCustomCompareEffect.ts index 8f189188..b5c73142 100644 --- a/src/useCustomCompareEffect.ts +++ b/src/useCustomCompareEffect.ts @@ -2,9 +2,13 @@ import { DependencyList, EffectCallback, useEffect, useRef } from 'react'; const isPrimitive = (val: any) => val !== Object(val); -type DepsEqualFnType = (prevDeps: DependencyList, nextDeps: DependencyList) => boolean; +type DepsEqualFnType = (prevDeps: TDeps, nextDeps: TDeps) => boolean; -const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, depsEqual: DepsEqualFnType) => { +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 +25,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; diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 698de2ec..2044e3f6 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -import { useEffect, useState } from 'react'; +import { useState, useCallback, Dispatch, SetStateAction } from 'react'; import { isClient } from './util'; type parserOptions = @@ -12,21 +12,26 @@ type parserOptions = deserializer: (value: string) => T; }; +const noop = () => {}; + const useLocalStorage = ( key: string, initialValue?: T, options?: parserOptions -): [T, React.Dispatch>] => { +): [T | undefined, Dispatch>, () => void] => { if (!isClient) { - return [initialValue as T, () => {}]; + return [initialValue as T, noop, noop]; + } + 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; - const deserializer = options ? (options.raw ? String : options.deserializer) : JSON.parse; + const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse; - const [state, setState] = useState(() => { + const [state, setState] = useState(() => { try { + const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify; + const localStorageValue = localStorage.getItem(key); if (localStorageValue !== null) { return deserializer(localStorageValue); @@ -42,16 +47,42 @@ const useLocalStorage = ( } }); - useEffect(() => { + const set: Dispatch> = useCallback( + valOrFunc => { + try { + const newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc; + if (typeof newState === 'undefined') return; + let value: string; + + if (options) + if (options.raw) + if (typeof newState === 'string') value = newState; + else value = JSON.stringify(newState); + else if (options.serializer) value = options.serializer(newState); + else value = JSON.stringify(newState); + else value = JSON.stringify(newState); + + localStorage.setItem(key, value); + setState(deserializer(value)); + } catch { + // If user is in private mode or has storage restriction + // localStorage can throw. Also JSON.stringify can throw. + } + }, + [key, setState] + ); + + const remove = useCallback(() => { try { - localStorage.setItem(key, serializer(state)); + localStorage.removeItem(key); + setState(undefined); } catch { // If user is in private mode or has storage restriction - // localStorage can throw. Also JSON.stringify can throw. + // localStorage can throw. } - }, [state]); + }, [key, setState]); - return [state, setState]; + return [state, set, remove]; }; export default useLocalStorage; diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 4622355f..a5bee4aa 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,40 +1,51 @@ -import { useCallback, useState } from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; +import { useState, useMemo } from 'react'; +import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; +import { isClient } from './util'; -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: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, +}; - const [observer] = useState( +const useMeasure = (): UseMeasureResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(defaultState); + + const observer = useMemo( () => - new ResizeObserver(entries => { - const entry = entries[0]; - if (entry) { - set(entry.contentRect); + 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 }); } - }) + }), + [] ); - const ref = useCallback( - node => { + useIsomorphicLayoutEffect(() => { + if (!element) return; + observer.observe(element); + return () => { observer.disconnect(); - if (node) { - observer.observe(node); - } - }, - [observer] - ); + }; + }, [element]); + return [ref, rect]; }; -export default useMeasure; +const useMeasureMock = () => [() => {}, defaultState]; + +export default (isClient && !!(window as any).ResizeObserver) ? useMeasure : useMeasureMock; diff --git a/src/useUpdate.ts b/src/useUpdate.ts index d7f0d368..bcc4511a 100644 --- a/src/useUpdate.ts +++ b/src/useUpdate.ts @@ -1,11 +1,10 @@ -import { useCallback, useState } from 'react'; +import { useReducer } 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 as () => void; }; export default useUpdate; diff --git a/src/util.ts b/src/util.ts index 7a930a4e..99f72b21 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,4 +4,8 @@ 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; + export const isDeepEqual: (a: any, b: any) => boolean = require('fast-deep-equal/react'); diff --git a/stories/useLocalStorage.story.tsx b/stories/useLocalStorage.story.tsx index 31f8e870..f246c960 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}
+ + +
); }; diff --git a/tests/setupTests.ts b/tests/setupTests.ts index 1c787210..c0ced93c 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -1 +1,6 @@ import 'jest-localstorage-mock'; + +(window as any).ResizeObserver = class ResizeObserver { + observe() {} + disconnect() {} +}; diff --git a/tests/useAsync.test.tsx b/tests/useAsync.test.tsx index 21145a3e..5373a7fb 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: { diff --git a/tests/useAsyncFn.test.tsx b/tests/useAsyncFn.test.tsx index 63ca25b2..4dcebfc3 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'); + }); }); diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts new file mode 100644 index 00000000..fd8de8eb --- /dev/null +++ b/tests/useCopyToClipboard.test.ts @@ -0,0 +1,103 @@ +import writeText from 'copy-to-clipboard'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useCopyToClipboard } from '../src'; + +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; + }) +); +jest.spyOn(global.console, 'error').mockImplementation(() => {}); + +describe('useCopyToClipboard', () => { + let hook; + + beforeEach(() => { + hook = renderHook(() => useCopyToClipboard()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should be defined ', () => { + expect(useCopyToClipboard).toBeDefined(); + }); + + 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)); + [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 not call writeText if passed an invalid input and set state', () => { + let testValue = {}; // invalid value + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).not.toBeCalled(); + 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; + + 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', () => { + 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); + }); + + 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(); + }); +}); diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts index 95340c54..da01dc86 100644 --- a/tests/useLocalStorage.test.ts +++ b/tests/useLocalStorage.test.ts @@ -1,95 +1,237 @@ +/* eslint-disable */ +import useLocalStorage from '../src/useLocalStorage'; +import 'jest-localstorage-mock'; import { renderHook, act } from '@testing-library/react-hooks'; -import { useLocalStorage } from '../src'; -const STRINGIFIED_VALUE = '{"a":"b"}'; -const JSONIFIED_VALUE = { a: 'b' }; - -afterEach(() => { - localStorage.clear(); - jest.clearAllMocks(); -}); - -it('should return undefined if no initialValue provided and localStorage empty', () => { - const { result } = renderHook(() => useLocalStorage('some_key')); - - expect(result.current[0]).toBeUndefined(); -}); - -it('should set the value from existing localStorage key', () => { - const key = 'some_key'; - localStorage.setItem(key, STRINGIFIED_VALUE); - - const { result } = renderHook(() => useLocalStorage(key)); - - expect(result.current[0]).toEqual(JSONIFIED_VALUE); -}); - -it('should return initialValue if localStorage empty and set that to localStorage', () => { - const key = 'some_key'; - const value = 'some_value'; - - const { result } = renderHook(() => useLocalStorage(key, value)); - - expect(result.current[0]).toBe(value); - expect(localStorage.__STORE__[key]).toBe(`"${value}"`); -}); - -it('should return the value from localStorage if exists even if initialValue provied', () => { - const key = 'some_key'; - localStorage.setItem(key, STRINGIFIED_VALUE); - - const { result } = renderHook(() => useLocalStorage(key, 'random_value')); - - expect(result.current[0]).toEqual(JSONIFIED_VALUE); -}); - -it('should properly update the localStorage on change', () => { - const key = 'some_key'; - const updatedValue = { b: 'a' }; - const expectedValue = '{"b":"a"}'; - - const { result } = renderHook(() => useLocalStorage(key)); - - act(() => { - result.current[1](updatedValue); +describe(useLocalStorage, () => { + afterEach(() => { + localStorage.clear(); + jest.clearAllMocks(); }); - expect(result.current[0]).toBe(updatedValue); - expect(localStorage.__STORE__[key]).toBe(expectedValue); -}); - -describe('Options with 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('retrieves an existing value from localStorage', () => { + 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 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); + const { result } = renderHook(() => useLocalStorage('foo', 'bar')); + const [state] = result.current; + expect(state).toEqual('bar'); + expect(localStorage.__STORE__.foo).toEqual('"bar"'); }); -}); -describe('Options with raw false and provided serializer/deserializer', () => { - const serializer = (_: string) => '321'; - const deserializer = (_: string) => '123'; + 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('should return valid serialized value from existing localStorage key', () => { - const key = 'some_key'; - localStorage.setItem(key, STRINGIFIED_VALUE); + 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"'); + }); - const { result } = renderHook(() => - useLocalStorage(key, STRINGIFIED_VALUE, { raw: false, serializer, deserializer }) + 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('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')); + + const [foo1, setFoo] = result.current; + act(() => setFoo(null)); + rerender(); + + const [foo2] = result.current; + expect(foo1).toEqual(null); + expect(foo2).toEqual(null); + }); + + it('sets initialState if initialState is an object', () => { + renderHook(() => 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')); + + const [, setFoo] = result.current; + act(() => setFoo('baz')); + rerender(); + + const [foo] = result.current; + expect(foo).toEqual('baz'); + }); + + /* + it('keeps multiple hooks accessing the same key in sync', () => { + 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' }) ); - expect(result.current[0]).toBe('123'); + const [, setFoo] = result.current; + act(() => setFoo(state => ({ ...state!, fizz: 'buzz' }))); + rerender(); + + const [value] = result.current; + expect(value!.foo).toEqual('bar'); + expect(value!.fizz).toEqual('buzz'); + }); + + it('rejects nullish or undefined keys', () => { + const { result } = renderHook(() => useLocalStorage(null as any)); + try { + result.current; + fail('hook should have thrown'); + } catch (e) { + expect(String(e)).toMatch(/key may not be/i); + } + }); + + /* Enforces proper eslint react-hooks/rules-of-hooks usage */ + describe('eslint react-hooks/rules-of-hooks', () => { + it('memoizes an object between rerenders', () => { + const { result, rerender } = renderHook(() => 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); + }); + }); + + describe('Options: raw', () => { + 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; + + act(() => setFoo({ fizz: 'bang' } as any)); + rerender(); + + const [foo] = result.current; + expect(typeof foo).toBe('string'); + + expect(JSON.parse(foo!)).toBeInstanceOf(Object); + + // expect(JSON.parse(foo!).fizz).toEqual('bang'); + }); + + it('still forces setState to a string', () => { + localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' })); + const { result, rerender } = renderHook(() => useLocalStorage('foo', null, { raw: true })); + + const [, setFoo] = result.current; + + act(() => setFoo({ fizz: 'bang' } as any)); + rerender(); + + const [value] = result.current; + + expect(JSON.parse(value!).fizz).toEqual('bang'); + }); }); }); diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index a242865b..94c83b6e 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -1,78 +1,14 @@ -/* eslint-disable */ -import { act, renderHook } from '@testing-library/react-hooks'; -import useMeasure, { ContentRect } from '../src/useMeasure'; +import { renderHook, act } from '@testing-library/react-hooks'; +import useMeasure, { UseMeasureRef } 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, - }; - }) - ); - } - } - }; -}); - -it('reacts to changes in size of any of the observed elements', () => { +it('by default, state defaults every value to -1', () => { const { result } = renderHook(() => useMeasure()); - const div = document.createElement('div'); - result.current[0](div); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as UseMeasureRef)(div); + }); + expect(result.current[1]).toMatchObject({ width: 0, height: 0, @@ -81,9 +17,63 @@ it('reacts to changes in size of any of the observed elements', () => { left: 0, right: 0, }); - act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100')); +}); + +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('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()); + + 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, + } + }]); + }); expect(result.current[1]).toMatchObject({ + x: 1, + y: 2, width: 200, height: 200, top: 100, @@ -92,3 +82,96 @@ 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 { + 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); +});