v15 release

BREAKING CHANGE: implementation of useMeasure and useLocalStorage changed
This commit is contained in:
Vadim Dalecky 2020-05-16 15:28:55 +02:00 committed by GitHub
commit 0f82ba650e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 790 additions and 244 deletions

99
.github/FUNDING.yml vendored
View File

@ -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"
]

View File

@ -32,5 +32,5 @@ const Demo = ({url}) => {
## Reference
```ts
useAsyncFn(fn, deps?: any[]);
useAsyncFn<Result, Args>(fn, deps?: any[], initialState?: AsyncState<Result>);
```

View File

@ -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 (
<div>
<div>Value: {value}</div>
<button onClick={() => setValue('bar')}>bar</button>
<button onClick={() => setValue('baz')}>baz</button>
<button onClick={() => remove()}>Remove</button>
</div>
);
};

View File

@ -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

View File

@ -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 @@
"<rootDir>/tests/**/*.test.(ts|tsx)"
],
"setupFiles": [
"./tests/setupTests.ts"
"<rootDir>/tests/setupTests.ts"
]
}
}

View File

@ -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<Result = any, Args extends any[] = any[]>(
fn: (...args: Args | []) => Promise<Result>,
deps: DependencyList = []
) {
const [state, callback] = useAsyncFn<Result, Args>(fn, deps, {
export default function useAsync<T extends FnReturningPromise>(fn: T, deps: DependencyList = []) {
const [state, callback] = useAsyncFn(fn, deps, {
loading: true,
});

View File

@ -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<T> =
| {
@ -8,6 +9,11 @@ export type AsyncState<T> =
error?: undefined;
value?: undefined;
}
| {
loading: true;
error?: Error | undefined;
value?: T;
}
| {
loading: false;
error: Error;
@ -19,24 +25,22 @@ export type AsyncState<T> =
value: T;
};
export type AsyncFn<Result = any, Args extends any[] = any[]> = [
AsyncState<Result>,
(...args: Args | []) => Promise<Result>
];
type StateFromFnReturningPromise<T extends FnReturningPromise> = AsyncState<PromiseType<ReturnType<T>>>;
export default function useAsyncFn<Result = any, Args extends any[] = any[]>(
fn: (...args: Args | []) => Promise<Result>,
export type AsyncFnReturn<T extends FnReturningPromise = FnReturningPromise> = [StateFromFnReturningPromise<T>, T];
export default function useAsyncFn<T extends FnReturningPromise>(
fn: T,
deps: DependencyList = [],
initialState: AsyncState<Result> = { loading: false }
): AsyncFn<Result, Args> {
initialState: StateFromFnReturningPromise<T> = { loading: false }
): AsyncFnReturn<T> {
const lastCallId = useRef(0);
const [state, set] = useState<AsyncState<Result>>(initialState);
const isMounted = useMountedState();
const [state, set] = useState<StateFromFnReturningPromise<T>>(initialState);
const callback = useCallback((...args: Args | []) => {
const callback = useCallback((...args: Parameters<T>): ReturnType<T> => {
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<Result = any, Args extends any[] = any[]>(
return error;
}
);
) as ReturnType<T>;
}, deps);
return [state, callback];
return [state, (callback as unknown) as T];
}

View File

@ -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,
});
}
}, []);

View File

@ -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<TDeps extends DependencyList> = (prevDeps: TDeps, nextDeps: TDeps) => boolean;
const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, depsEqual: DepsEqualFnType) => {
const useCustomCompareEffect = <TDeps extends DependencyList>(
effect: EffectCallback,
deps: TDeps,
depsEqual: DepsEqualFnType<TDeps>
) => {
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<DependencyList | undefined>(undefined);
const ref = useRef<TDeps | undefined>(undefined);
if (!ref.current || !depsEqual(deps, ref.current)) {
ref.current = deps;

View File

@ -1,5 +1,5 @@
/* eslint-disable */
import { useEffect, useState } from 'react';
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
import { isClient } from './util';
type parserOptions<T> =
@ -12,21 +12,26 @@ type parserOptions<T> =
deserializer: (value: string) => T;
};
const noop = () => {};
const useLocalStorage = <T>(
key: string,
initialValue?: T,
options?: parserOptions<T>
): [T, React.Dispatch<React.SetStateAction<T>>] => {
): [T | undefined, Dispatch<SetStateAction<T | undefined>>, () => 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<T>(() => {
const [state, setState] = useState<T | undefined>(() => {
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 = <T>(
}
});
useEffect(() => {
const set: Dispatch<SetStateAction<T | undefined>> = 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;

View File

@ -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<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>;
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 = <T>(): [(instance: T) => void, ContentRect] => {
const [rect, set] = useState<ContentRect>({
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<HTMLElement | null>(null);
const [rect, setRect] = useState<UseMeasureRect>(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;

View File

@ -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;

View File

@ -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<any>;
export type PromiseType<P extends Promise<any>> = P extends Promise<infer T> ? T : never;
export const isDeepEqual: (a: any, b: any) => boolean = require('fast-deep-equal/react');

View File

@ -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 (
<div>
<div>Value: {value}</div>
<button onClick={() => setValue('bar')}>bar</button>
<button onClick={() => setValue('baz')}>baz</button>
<br />
<br />
<div>Removable Value: {removableValue}</div>
<button onClick={() => setRemovableValue('foo')}>foo</button>
<button onClick={() => setRemovableValue('bar')}>bar</button>
<button onClick={() => remove()}>Remove</button>
</div>
);
};

View File

@ -1 +1,6 @@
import 'jest-localstorage-mock';
(window as any).ResizeObserver = class ResizeObserver {
observe() {}
disconnect() {}
};

View File

@ -148,7 +148,7 @@ describe('useAsync', () => {
hook = renderHook(
({ fn, counter }) => {
const callback = useCallback(() => fn(counter), [counter]);
return useAsync<string>(callback, [callback]);
return useAsync<any>(callback, [callback]);
},
{
initialProps: {

View File

@ -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<string> }, [AsyncState<string>, () => Promise<string>]>(
({ 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');
});
});

View File

@ -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();
});
});

View File

@ -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');
});
});
});

View File

@ -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<HTMLElement, any>;
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);
});