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

feat: useMutableCallback (#156)

* Add useMutableCallback hook

* Apply lint on tests

* Replace testHook with runHooks
parent d1343560
import React from 'react';
import ReactDOM from 'react-dom';
import React, { useReducer, Component, createElement } from 'react';
import ReactDOM, { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
export const testHook = (callback, ...acts) => {
export const runHooks = (fn, mutations = []) => {
let returnedValue;
let forceUpdate;
function FunctionalComponent() {
[, forceUpdate] = useReducer((state) => !state, false);
returnedValue = fn();
return null;
}
let errorThrown;
class ErrorBoundary extends React.Component {
class ComponentWithErrorBoundary extends Component {
state = { errored: false }
static getDerivedStateFromError = () => ({ errored: true })
......@@ -15,29 +23,35 @@ export const testHook = (callback, ...acts) => {
errorThrown = error;
}
render = () => (this.state.errored ? null : <>{this.props.children}</>)
}
function TestComponent() {
returnedValue = callback();
return null;
render = () => (this.state.errored ? null : createElement(FunctionalComponent))
}
const spy = jest.spyOn(console, 'error');
spy.mockImplementation(() => {});
const div = document.createElement('div');
ReactDOM.render(<ErrorBoundary>
<TestComponent />
</ErrorBoundary>, div);
render(createElement(ComponentWithErrorBoundary), div);
const values = [returnedValue];
for (const mutation of mutations) {
act(() => {
forceUpdate();
acts.forEach((fn) => act(fn.bind(null, returnedValue)));
if (mutation === true) {
return;
}
mutation(returnedValue);
});
values.push(returnedValue);
}
ReactDOM.unmountComponentAtNode(div);
unmountComponentAtNode(div);
if (errorThrown) {
throw errorThrown;
}
return returnedValue;
return values;
};
......@@ -43,8 +43,10 @@ yarn test
- [Parameters](#parameters-6)
- [useMergedRefs](#usemergedrefs)
- [Parameters](#parameters-7)
- [useToggle](#usetoggle)
- [useMutableCallback](#usemutablecallback)
- [Parameters](#parameters-8)
- [useToggle](#usetoggle)
- [Parameters](#parameters-9)
### useClassName
......@@ -141,6 +143,16 @@ while receiving a forwared ref.
Returns **any** a merged callback ref
### useMutableCallback
Hook to create a stable callback from a mutable one.
#### Parameters
- `fn` **function (): any** the mutable callback
Returns **any** a stable callback
### useToggle
Hook to create a toggleable boolean state.
......
......@@ -29,7 +29,7 @@
"start": "rollup -c -w",
"build": "rollup -c",
"test": "jest",
"lint": "eslint src",
"lint": "eslint src tests",
"lint-staged": "lint-staged",
"docs": "documentation readme src/index.js --section='API Reference' --readme-file README.md"
},
......
......@@ -6,5 +6,6 @@ export * from './useDebouncedCallback';
export * from './useExclusiveBooleanProps';
export * from './useMediaQuery';
export * from './useMergedRefs';
export * from './useMutableCallback';
export * from './useToggle';
export * from './useUniqueId';
// @flow
import { useCallback, useRef } from 'react';
/**
* Hook to create a stable callback from a mutable one.
*
* @param fn the mutable callback
* @return a stable callback
*/
export const useMutableCallback = (fn: (...args : any[]) => any) => {
const fnRef = useRef(fn);
fnRef.current = fn;
return useCallback((...args: any[]) => fnRef.current && (0, fnRef.current)(...args), []);
};
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useClassName } from '../src';
describe('useClassName hook', () => {
const componentClassName = 'component';
it('accepts only the component className', () => {
const newClassName = testHook(() => useClassName(componentClassName));
const [newClassName] = runHooks(() => useClassName(componentClassName));
expect(newClassName).toEqual(componentClassName);
});
it('composes with a true-valued boolean modifier', () => {
const newClassName = testHook(() => useClassName(componentClassName, { a: true }));
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: true }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a`);
});
it('does not compose with a false-valued boolean modifier', () => {
const newClassName = testHook(() => useClassName(componentClassName, { a: false }));
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: false }));
expect(newClassName).toEqual(componentClassName);
});
it('composes with a non-boolean modifier', () => {
const newClassName = testHook(() => useClassName(componentClassName, { a: 'b' }));
const [newClassName] = runHooks(() => 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 = testHook(() => useClassName(componentClassName, {}, ...classNames));
const [newClassName] = runHooks(() => useClassName(componentClassName, {}, ...classNames));
expect(newClassName).toEqual(`${ componentClassName } ${ classNames.join(' ') }`);
});
it('formats a modifier name from camelCase to kebab-case', () => {
const newClassName = testHook(() => useClassName(componentClassName, { camelCaseModifier: true }));
const [newClassName] = runHooks(() => useClassName(componentClassName, { camelCaseModifier: true }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--camel-case-modifier`);
});
it('formats a modifier value from camelCase to kebab-case', () => {
const newClassName = testHook(() => useClassName(componentClassName, { a: 'camelCaseValue' }));
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: 'camelCaseValue' }));
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a-camel-case-value`);
});
});
import { useState } from 'react';
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useDebouncedCallback } from '../src';
describe('useDebouncedCallback hook', () => {
......@@ -13,7 +11,7 @@ describe('useDebouncedCallback hook', () => {
});
it('returns a debounced callback', () => {
const debouncedCallback = testHook(() => useDebouncedCallback(fn, delay));
const [debouncedCallback] = runHooks(() => useDebouncedCallback(fn, delay));
expect(debouncedCallback).toBeInstanceOf(Function);
expect(debouncedCallback.flush).toBeInstanceOf(Function);
expect(debouncedCallback.cancel).toBeInstanceOf(Function);
......@@ -24,69 +22,30 @@ describe('useDebouncedCallback hook', () => {
});
it('returns the same callback if deps don\'t change', () => {
let callbackA;
let callbackB;
let setDummy;
testHook(
() => {
[, setDummy] = useState(0);
return useDebouncedCallback(fn, delay, []);
},
(returnedValue) => {
callbackA = returnedValue;
setDummy((dep) => dep + 1);
},
(returnedValue) => {
callbackB = returnedValue;
}
);
const [callbackA, callbackB] = runHooks(() => useDebouncedCallback(fn, delay, []), [true]);
expect(callbackA).toBe(callbackB);
});
it('returns another callback if deps change', () => {
let callbackA;
let callbackB;
let dep;
let setDep;
let dep = Symbol();
testHook(
const [callbackA, , callbackB] = runHooks(() => useDebouncedCallback(fn, delay, [dep]), [
() => {
[dep, setDep] = useState(0);
return useDebouncedCallback(fn, delay, [dep]);
dep = Symbol();
},
(returnedValue) => {
callbackA = returnedValue;
setDep((dep) => dep + 1);
},
(returnedValue) => {
callbackB = returnedValue;
}
);
]);
expect(callbackA).not.toBe(callbackB);
});
it('returns another callback if delay change', () => {
let callbackA;
let callbackB;
let delay;
let setDelay;
let delay = 0;
testHook(
const [callbackA, callbackB] = runHooks(() => useDebouncedCallback(fn, delay, []), [
() => {
[delay, setDelay] = useState(0);
return useDebouncedCallback(fn, delay, []);
},
(returnedValue) => {
callbackA = returnedValue;
setDelay((delay) => delay + 1);
delay = 1;
},
(returnedValue) => {
callbackB = returnedValue;
}
);
]);
expect(callbackA).not.toBe(callbackB);
});
......
import { useState } from 'react';
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useDebouncedUpdates, useDebouncedReducer, useDebouncedState } from '../src';
describe('useDebouncedUpdates hook', () => {
......@@ -11,23 +11,10 @@ describe('useDebouncedUpdates hook', () => {
});
it('returns a debounced state updater', () => {
let valueA;
let valueB;
let valueC;
const [, debouncedSetValue] = testHook(
() => useDebouncedUpdates(useState(0), delay),
([value, debouncedSetValue]) => {
valueA = value;
debouncedSetValue((value) => value + 1);
},
([value]) => {
valueB = value;
jest.runAllTimers();
},
([value]) => {
valueC = value;
}
);
const [[valueA, debouncedSetValue], [valueB], [valueC]] = runHooks(() => useDebouncedUpdates(useState(0), delay), [
([, setValue]) => setValue((value) => value + 1),
() => jest.runAllTimers(),
]);
expect(debouncedSetValue).toBeInstanceOf(Function);
expect(debouncedSetValue.flush).toBeInstanceOf(Function);
......@@ -39,55 +26,46 @@ describe('useDebouncedUpdates hook', () => {
describe('useDebouncedReducer hook', () => {
it('is a debounced state updater', () => {
const initialState = {};
const newState = {};
const initialState = Symbol();
const newState = Symbol();
const reducer = jest.fn(() => newState);
const initializerArg = initialState;
const initializer = jest.fn((state) => state);
let stateA;
let stateB;
testHook(
() => useDebouncedReducer(reducer, initializerArg, initializer, delay),
([, dispatch]) => {
dispatch();
},
([state]) => {
stateA = state;
jest.runAllTimers();
},
([state]) => {
stateB = state;
}
);
const [
[stateA], [stateB], [stateC],
] = runHooks(() => useDebouncedReducer(reducer, initializerArg, initializer, delay), [
([, dispatch]) => dispatch(),
() => jest.runAllTimers(),
]);
expect(reducer).toHaveBeenCalledWith(initialState, undefined);
expect(initializer).toHaveBeenCalledWith(initializerArg);
expect(stateA).toBe(initialState);
expect(stateB).toBe(newState);
expect(stateB).toBe(initialState);
expect(stateC).toBe(newState);
});
});
describe('useDebouncedState hook', () => {
it('is a debounced state updater', () => {
const initialValue = {};
const newValue = {};
let valueA;
let valueB;
testHook(
() => useDebouncedState(initialValue, delay),
const initialValue = Symbol();
const newValue = Symbol();
const [
[valueA], [valueB], [valueC],
] = runHooks(() => useDebouncedState(initialValue, delay), [
([, setValue]) => {
setValue(newValue);
},
([state]) => {
valueA = state;
() => {
jest.runAllTimers();
},
([state]) => {
valueB = state;
}
);
]);
expect(valueA).toBe(initialValue);
expect(valueB).toBe(newValue);
expect(valueB).toBe(initialValue);
expect(valueC).toBe(newValue);
});
});
});
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useExclusiveBooleanProps } from '../src';
describe('useExclusiveBooleanProps hook', () => {
it('allows an empty set of props', () => {
testHook(() => useExclusiveBooleanProps({}));
runHooks(() => useExclusiveBooleanProps({}));
});
it('allows only false-valued props', () => {
testHook(() => useExclusiveBooleanProps({ a: false, b: false, c: false }));
runHooks(() => useExclusiveBooleanProps({ a: false, b: false, c: false }));
});
it('allows one true-valued prop', () => {
testHook(() => useExclusiveBooleanProps({ a: true }));
runHooks(() => useExclusiveBooleanProps({ a: true }));
});
it('allows one true-valued prop among false-valued ones', () => {
testHook(() => useExclusiveBooleanProps({ a: true, b: false, c: false }));
runHooks(() => useExclusiveBooleanProps({ a: true, b: false, c: false }));
});
it('denies two true-valued props', () => {
expect(() => {
testHook(() => useExclusiveBooleanProps({ a: true, b: true }));
runHooks(() => useExclusiveBooleanProps({ a: true, b: true }));
}).toThrow();
});
});
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useMediaQuery } from '../src';
describe('useMediaQuery hook', () => {
......@@ -23,44 +23,40 @@ describe('useMediaQuery hook', () => {
});
it('does not register a undefined media query', () => {
testHook(() => useMediaQuery());
runHooks(() => useMediaQuery());
expect(window.matchMedia).not.toHaveBeenCalled();
});
it('does register a defined media query', () => {
testHook(() => useMediaQuery('(max-width: 1024)'));
runHooks(() => useMediaQuery('(max-width: 1024)'));
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 1024)');
});
it('returns false if no query is given', () => {
const value = testHook(() => useMediaQuery());
const [value] = runHooks(() => useMediaQuery());
expect(value).toBe(false);
});
it('returns false if the media query does not match', () => {
const value = testHook(() => useMediaQuery('(max-width: 1024)'));
const [value] = runHooks(() => useMediaQuery('(max-width: 1024)'));
expect(value).toBe(false);
});
it('returns true if the media query does match', () => {
mql.matches = true;
const value = testHook(() => useMediaQuery('(max-width: 1024)'));
const [value] = runHooks(() => useMediaQuery('(max-width: 1024)'));
expect(value).toBe(true);
});
it('mutates its value to true if the media query matches', () => {
testHook(
() => useMediaQuery('(max-width: 1024)'),
(matches) => {
expect(matches).toBe(false);
},
const [matchesA, matchesB] = runHooks(() => useMediaQuery('(max-width: 1024)'), [
() => {
mql.matches = true;
mql.onchange();
},
(matches) => {
expect(matches).toBe(true);
}
);
]);
expect(matchesA).toBe(false);
expect(matchesB).toBe(true);
});
});
import React from 'react';
import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useMergedRefs } from '../src';
describe('useMergedRefs hook', () => {
it('returns a callback ref', () => {
const mergedRef = testHook(() => useMergedRefs());
const [mergedRef] = runHooks(() => useMergedRefs());
expect(mergedRef).toStrictEqual(expect.any(Function));
});
it('works without any arguments', () => {
const value = {};
const mergedRef = testHook(
() => jest.fn(useMergedRefs()),
(mergedRef) => {
mergedRef(value);
},
);
const [mergedRef] = runHooks(() => jest.fn(useMergedRefs()));
const value = Symbol();
mergedRef(value);
expect(mergedRef).toHaveBeenCalledWith(value);
});
it('works with one ref', () => {
const ref = React.createRef();
const value = {};
testHook(
() => useMergedRefs(ref),
(mergedRef) => {
mergedRef(value);
},
);
const [mergedRef] = runHooks(() => useMergedRefs(ref));
const value = Symbol();
mergedRef(value);
expect(ref.current).toBe(value);
});
it('works with many refs', () => {
const refs = new Array(10).fill(undefined).map(() => React.createRef());
const value = {};
testHook(
() => useMergedRefs(...refs),
(mergedRef) => {
mergedRef(value);
},
);
const [mergedRef] = runHooks(() => useMergedRefs(...refs));
const value = Symbol();
mergedRef(value);
refs.forEach((ref) => expect(ref.current).toBe(value));
});
it('works with callback ref', () => {
const callbackRef = jest.fn();
const value = {};
testHook(
() => useMergedRefs(callbackRef),
(mergedRef) => {
mergedRef(value);
},
);
const [mergedRef] = runHooks(() => useMergedRefs(callbackRef));
const value = Symbol();
mergedRef(value);
expect(callbackRef).toHaveBeenCalledWith(value);
});
it('works with refs and callback refs', () => {
const refs = new Array(5).fill(undefined).map(() => React.createRef());
const callbackRefs = new Array(5).fill(undefined).map(() => jest.fn());
const [mergedRef] = runHooks(() => useMergedRefs(...refs, ...callbackRefs));
const value = {};
testHook(
() => useMergedRefs(...refs, ...callbackRefs),
(mergedRef) => {
mergedRef(value);
},
);
const value = Symbol();
mergedRef(value);
refs.forEach((ref) => expect(ref.current).toBe(value));
callbackRefs.forEach((callbackRef) => expect(callbackRef).toHaveBeenCalledWith(value));