Unverified Commit ca620a04 authored by Tasso Evangelista's avatar Tasso Evangelista Committed by GitHub
Browse files

feat: New hooks and server-side compatibility (#203)

parent 5ab9f6a0
......@@ -8,5 +8,6 @@ flow-typed
[lints]
[options]
esproposal.optional_chaining=enable
[strict]
import React, { useReducer, Component, createElement } from 'react';
import ReactDOM, { render, unmountComponentAtNode } from 'react-dom';
import { renderToString } from 'react-dom/server';
import { act } from 'react-dom/test-utils';
export const runHooks = (fn, mutations = []) => {
......@@ -55,3 +56,16 @@ export const runHooks = (fn, mutations = []) => {
return values;
};
export const runHooksOnServer = (fn) => {
let returnedValue;
function FunctionalComponent() {
returnedValue = fn();
return null;
}
renderToString(<FunctionalComponent />);
return returnedValue;
};
......@@ -27,26 +27,43 @@ yarn test
#### Table of Contents
- [useClassName](#useclassname)
- [useAutoFocus](#useautofocus)
- [Parameters](#parameters)
- [useDebouncedUpdates](#usedebouncedupdates)
- [useClassName](#useclassname)
- [Parameters](#parameters-1)
- [useDebouncedReducer](#usedebouncedreducer)
- [useDebouncedUpdates](#usedebouncedupdates)
- [Parameters](#parameters-2)
- [useDebouncedState](#usedebouncedstate)
- [useDebouncedReducer](#usedebouncedreducer)
- [Parameters](#parameters-3)
- [useDebouncedCallback](#usedebouncedcallback)
- [useDebouncedState](#usedebouncedstate)
- [Parameters](#parameters-4)
- [useExclusiveBooleanProps](#useexclusivebooleanprops)
- [useDebouncedCallback](#usedebouncedcallback)
- [Parameters](#parameters-5)
- [useMediaQuery](#usemediaquery)
- [useDebouncedValue](#usedebouncedvalue)
- [Parameters](#parameters-6)
- [useMergedRefs](#usemergedrefs)
- [useLazyRef](#uselazyref)
- [Parameters](#parameters-7)
- [useMutableCallback](#usemutablecallback)
- [useMediaQuery](#usemediaquery)
- [Parameters](#parameters-8)
- [useToggle](#usetoggle)
- [useMergedRefs](#usemergedrefs)
- [Parameters](#parameters-9)
- [useMutableCallback](#usemutablecallback)
- [Parameters](#parameters-10)
- [useSafely](#usesafely)
- [Parameters](#parameters-11)
- [useToggle](#usetoggle)
- [Parameters](#parameters-12)
### useAutoFocus
Hook to automatically request focus for an DOM element.
#### Parameters
- `isFocused` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** if true, the focus will be requested (optional, default `true`)
- `options` **FocusOptions** options of the focus request
Returns **any** the ref which holds the element
### useClassName
......@@ -110,17 +127,26 @@ Hook to memoize a debounced version of a callback.
Returns **function (): any** a memoized and debounced callback
### useExclusiveBooleanProps
### useDebouncedValue
Hook for asserting mutually exclusive boolean props. Useful for components that use boolean props
to choose styling variants.
Hook to keep a debounced reference of a value.
#### Parameters
- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** the mutually exclusive boolean props
- `value` **any** the value to be debounced
- `delay` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** the number of milliseconds to delay
Returns **any** a debounced value
### useLazyRef
Hook equivalent to useRef, but with a lazy initialization for computed value.
#### Parameters
- Throws **any** if two or more booleans props are set as true
- `initializer` **function (): T** the function the computes the ref value
Returns **any** the ref
### useMediaQuery
......@@ -153,6 +179,19 @@ Hook to create a stable callback from a mutable one.
Returns **any** a stable callback
### useSafely
Hook that wraps pairs of state and updater to provide a new updater which
can be safe and asynchronically called even after the component unmounted.
#### Parameters
- `pair` **\[any, function (): any]** the state and updater pair which will be patched
- `pair.0` the state value
- `pair.1` the state updater function
Returns **any** a state value and safe updater pair
### useToggle
Hook to create a toggleable boolean state.
......
......@@ -24,3 +24,5 @@ export const debounce = (fn: (...Array<any>) => any, delay: number) => {
return f;
};
export const isRunningOnBrowser = typeof window !== 'undefined' && window.document;
// @flow
export * from './useAutoFocus';
export * from './useClassName';
export * from './useDebouncedUpdates';
export * from './useDebouncedCallback';
export * from './useExclusiveBooleanProps';
export * from './useDebouncedValue';
export * from './useLazyRef';
export * from './useMediaQuery';
export * from './useMergedRefs';
export * from './useMutableCallback';
export * from './useSafely';
export * from './useToggle';
export * from './useUniqueId';
// @flow
import { useEffect, useRef } from 'react';
type FocusOptions = {
preventScroll?: boolean,
} | typeof undefined;
/**
* Hook to automatically request focus for an DOM element.
*
* @param isFocused if true, the focus will be requested
* @param options options of the focus request
* @return the ref which holds the element
*/
export const useAutoFocus = (isFocused: boolean = true, options: FocusOptions) => {
const elementRef = useRef<?HTMLElement>();
useEffect(() => {
if (isFocused && elementRef.current) {
elementRef.current.focus(options);
}
}, [elementRef, isFocused]);
return elementRef;
};
// @flow
import { useEffect, useState } from 'react';
/**
* Hook to keep a debounced reference of a value.
*
* @param value the value to be debounced
* @param delay the number of milliseconds to delay
* @return a debounced value
*/
export const useDebouncedValue = (value: any, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
// @flow
import invariant from 'invariant';
/**
* Hook for asserting mutually exclusive boolean props. Useful for components that use boolean props
* to choose styling variants.
*
* @param props - the mutually exclusive boolean props
* @throws if two or more booleans props are set as true
*/
export const useExclusiveBooleanProps = (props: Object) =>
invariant(
Object.values(props).filter(Boolean).length <= 1,
`Only one property of [${ Object.keys(props).join(', ') }] should be true`,
);
// @flow
import { createRef, useState } from 'react';
/**
* Hook equivalent to useRef, but with a lazy initialization for computed value.
*
* @param initializer the function the computes the ref value
* @return the ref
*/
export const useLazyRef = <T>(initializer: () => T) =>
useState(() => {
const ref = createRef<T>();
ref.current = initializer();
return ref;
})[0];
// @flow
import { useLayoutEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { isRunningOnBrowser } from './helpers';
/**
* Hook to listen to a media query.
......@@ -10,7 +12,7 @@ import { useLayoutEffect, useState } from 'react';
*/
export const useMediaQuery = (query: string): bool => {
const [matches, setMatches] = useState(() => {
if (!query) {
if (!query || !isRunningOnBrowser) {
return false;
}
......@@ -18,28 +20,22 @@ export const useMediaQuery = (query: string): bool => {
return !!matches;
});
useLayoutEffect(() => {
if (!query) {
useEffect(() => {
if (!query || !isRunningOnBrowser) {
return;
}
let mounted = true;
const mql = window.matchMedia(query);
const mediaQueryListener = window.matchMedia(query);
setMatches(mediaQueryListener.matches);
const handleChange = () => {
if (!mounted) {
return;
}
setMatches(!!mql.matches);
setMatches(!!mediaQueryListener.matches);
};
mql.addListener(handleChange);
setMatches(mql.matches);
mediaQueryListener.addListener(handleChange);
return () => {
mounted = false;
mql.removeListener(handleChange);
mediaQueryListener.removeListener(handleChange);
};
}, [query]);
......
// @flow
import { useEffect, useRef } from 'react';
import { useMutableCallback } from './useMutableCallback';
/**
* Hook that wraps pairs of state and updater to provide a new updater which
* can be safe and asynchronically called even after the component unmounted.
*
* @param pair - the state and updater pair which will be patched
* @param pair.0 - the state value
* @param pair.1 - the state updater function
* @return a state value and safe updater pair
*/
export const useSafely = ([state, updater]: [any, () => any]) => {
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
});
const safeUpdater = useMutableCallback((...args) => {
if (!mountedRef.current) {
return;
}
updater(...args);
});
return [state, safeUpdater];
};
import { useState } from 'react';
import { runHooks } from '../.jest/helpers';
import { useAutoFocus } from '../src';
describe('useAutoFocus hook', () => {
it('returns a ref', () => {
const [ref] = runHooks(() => useAutoFocus());
expect(ref).toMatchObject({ current: undefined });
});
it('invokes focus', () => {
const focus = jest.fn();
runHooks(() => useAutoFocus(), [
(ref) => {
ref.current = { focus };
},
]);
expect(focus).toHaveBeenCalledTimes(1);
});
it('does not invoke focus if isFocused is false', () => {
const focus = jest.fn();
runHooks(() => useAutoFocus(false), [
(ref) => {
ref.current = { focus };
},
]);
expect(focus).toHaveBeenCalledTimes(0);
});
it('invokes focus if isFocused is toggled', () => {
const focus = jest.fn();
runHooks(() => {
const [isFocused, setFocused] = useState(false);
return [useAutoFocus(isFocused), setFocused];
}, [
([ref]) => {
ref.current = { focus };
},
([, setFocused]) => {
setFocused(true);
},
]);
expect(focus).toHaveBeenCalledTimes(1);
});
});
/**
* @jest-environment node
*/
import { runHooksOnServer } from '../.jest/helpers';
import { useClassName } from '../src';
describe('useClassName hook on server', () => {
const componentClassName = 'component';
it('accepts only the component className', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName));
expect(newClassName).toEqual(componentClassName);
});
it('composes with a true-valued boolean modifier', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName, { a: true }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a`);
});
it('does not compose with a false-valued boolean modifier', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName, { a: false }));
expect(newClassName).toEqual(componentClassName);
});
it('composes with a non-boolean modifier', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName, { a: 'b' }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a-b`);
});
it('appends an arbitrary amount of additional classNames', () => {
const classNames = new Array(5).fill(undefined).map((i) => `class-${ i }`);
const newClassName = runHooksOnServer(() => useClassName(componentClassName, {}, ...classNames));
expect(newClassName).toEqual(`${ componentClassName } ${ classNames.join(' ') }`);
});
it('formats a modifier name from camelCase to kebab-case', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName, { camelCaseModifier: true }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--camel-case-modifier`);
});
it('formats a modifier value from camelCase to kebab-case', () => {
const newClassName = runHooksOnServer(() => useClassName(componentClassName, { a: 'camelCaseValue' }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a-camel-case-value`);
});
});
/**
* @jest-environment node
*/
import { runHooksOnServer } from '../.jest/helpers';
import { useDebouncedValue } from '../src';
describe('useDebouncedValue hook', () => {
let delay;
beforeEach(() => {
jest.useFakeTimers();
delay = Math.round(100 * Math.random());
});
it('returns the initial value', () => {
const mutableValue = Symbol();
const value = runHooksOnServer(() => useDebouncedValue(mutableValue, delay));
expect(value).toBe(mutableValue);
});
});
import { useState } from 'react';
import { runHooks } from '../.jest/helpers';
import { useDebouncedValue } from '../src';
describe('useDebouncedValue hook', () => {
let delay;
beforeEach(() => {
jest.useFakeTimers();
delay = Math.round(100 * Math.random());
});
it('returns the initial value immediately', () => {
const mutableValue = Symbol();
const [value] = runHooks(() => useDebouncedValue(mutableValue, delay));
expect(value).toBe(mutableValue);
});
it('returns the newest value after timeout', () => {
const [[valueA], [valueB], [valueC]] = runHooks(
() => {
const [mutableValue, setMutableValue] = useState(0);
return [useDebouncedValue(mutableValue, delay), setMutableValue];
},
[
([, setMutableValue]) => setMutableValue((mutableValue) => mutableValue + 1),
() => jest.runAllTimers(),
],
);
expect(valueA).toBe(0);
expect(valueB).toBe(0);
expect(valueC).toBe(1);
});
});
import { runHooks } from '../.jest/helpers';
import { useExclusiveBooleanProps } from '../src';
describe('useExclusiveBooleanProps hook', () => {
it('allows an empty set of props', () => {
runHooks(() => useExclusiveBooleanProps({}));
});
it('allows only false-valued props', () => {
runHooks(() => useExclusiveBooleanProps({ a: false, b: false, c: false }));
});
it('allows one true-valued prop', () => {
runHooks(() => useExclusiveBooleanProps({ a: true }));
});
it('allows one true-valued prop among false-valued ones', () => {
runHooks(() => useExclusiveBooleanProps({ a: true, b: false, c: false }));
});
it('denies two true-valued props', () => {
expect(() => {
runHooks(() => useExclusiveBooleanProps({ a: true, b: true }));
}).toThrow();
});
});
import { runHooksOnServer } from '../.jest/helpers';
import { useLazyRef } from '../src';
describe('useLazyRef hook on server', () => {
it('returns the computed value immediately', () => {
const computedValue = Symbol();
const value = runHooksOnServer(() => useLazyRef(() => computedValue));
expect(value.current).toBe(computedValue);
});
});
import { runHooks } from '../.jest/helpers';
import { useLazyRef } from '../src';
describe('useLazyRef hook', () => {
it('returns the computed value immediately', () => {
const computedValue = Symbol();
const [value] = runHooks(() => useLazyRef(() => computedValue));
expect(value.current).toBe(computedValue);
});
it('runs the initializer once', () => {
const initializer = jest.fn();
runHooks(() => useLazyRef(initializer), [true]);
expect(initializer).toHaveBeenCalledTimes(1);
});
});
/**
* @jest-environment node
*/
import { runHooksOnServer } from '../.jest/helpers';
import { useMediaQuery } from '../src';
describe('useMediaQuery hook on server', () => {
it('returns false for undefined media query', () => {
const value = runHooksOnServer(() => useMediaQuery());
expect(value).toBe(false);
});
it('returns false for defined media query', () => {
const value = runHooksOnServer(() => useMediaQuery('(max-width: 1024)'));
expect(value).toBe(false);
});
});
......@@ -49,14 +49,18 @@ describe('useMediaQuery hook', () => {
});
it('mutates its value to true if the media query matches', () => {
const [matchesA, matchesB] = runHooks(() => useMediaQuery('(max-width: 1024)'), [
const [matchesA, matchesB, matchesC] = runHooks(() => useMediaQuery('(max-width: 1024)'), [
() => {
mql.matches = true;
},
() => {
mql.matches = false;
mql.onchange();
},
]);
expect(matchesA).toBe(false);
expect(