Skip to content

Commit

Permalink
feat: useMutableCallback (#156)
Browse files Browse the repository at this point in the history
* Add useMutableCallback hook

* Apply lint on tests

* Replace testHook with runHooks
  • Loading branch information
tassoevan authored Mar 7, 2020
1 parent d134356 commit c43294f
Show file tree
Hide file tree
Showing 14 changed files with 211 additions and 226 deletions.
46 changes: 30 additions & 16 deletions packages/fuselage-hooks/.jest/helpers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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 })
Expand All @@ -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;
};
14 changes: 13 additions & 1 deletion packages/fuselage-hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/fuselage-hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions packages/fuselage-hooks/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 16 additions & 0 deletions packages/fuselage-hooks/src/useMutableCallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @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), []);
};
16 changes: 8 additions & 8 deletions packages/fuselage-hooks/tests/useClassName.spec.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
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`);
});
});
63 changes: 11 additions & 52 deletions packages/fuselage-hooks/tests/useDebouncedCallback.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { useState } from 'react';

import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useDebouncedCallback } from '../src';

describe('useDebouncedCallback hook', () => {
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down
76 changes: 27 additions & 49 deletions packages/fuselage-hooks/tests/useDebouncedUpdates.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';

import { testHook } from '../.jest/helpers';
import { runHooks } from '../.jest/helpers';
import { useDebouncedUpdates, useDebouncedReducer, useDebouncedState } from '../src';

describe('useDebouncedUpdates hook', () => {
Expand All @@ -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);
Expand All @@ -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);
});
});
});
Loading

0 comments on commit c43294f

Please sign in to comment.