Skip to content

Commit c43294f

Browse files
authored
feat: useMutableCallback (#156)
* Add useMutableCallback hook * Apply lint on tests * Replace testHook with runHooks
1 parent d134356 commit c43294f

14 files changed

+211
-226
lines changed
+30-16
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import React from 'react';
2-
import ReactDOM from 'react-dom';
1+
import React, { useReducer, Component, createElement } from 'react';
2+
import ReactDOM, { render, unmountComponentAtNode } from 'react-dom';
33
import { act } from 'react-dom/test-utils';
44

5-
export const testHook = (callback, ...acts) => {
5+
export const runHooks = (fn, mutations = []) => {
66
let returnedValue;
7+
let forceUpdate;
8+
9+
function FunctionalComponent() {
10+
[, forceUpdate] = useReducer((state) => !state, false);
11+
returnedValue = fn();
12+
return null;
13+
}
14+
715
let errorThrown;
816

9-
class ErrorBoundary extends React.Component {
17+
class ComponentWithErrorBoundary extends Component {
1018
state = { errored: false }
1119

1220
static getDerivedStateFromError = () => ({ errored: true })
@@ -15,29 +23,35 @@ export const testHook = (callback, ...acts) => {
1523
errorThrown = error;
1624
}
1725

18-
render = () => (this.state.errored ? null : <>{this.props.children}</>)
19-
}
20-
21-
function TestComponent() {
22-
returnedValue = callback();
23-
return null;
26+
render = () => (this.state.errored ? null : createElement(FunctionalComponent))
2427
}
2528

2629
const spy = jest.spyOn(console, 'error');
2730
spy.mockImplementation(() => {});
2831

2932
const div = document.createElement('div');
30-
ReactDOM.render(<ErrorBoundary>
31-
<TestComponent />
32-
</ErrorBoundary>, div);
33+
render(createElement(ComponentWithErrorBoundary), div);
34+
35+
const values = [returnedValue];
36+
37+
for (const mutation of mutations) {
38+
act(() => {
39+
forceUpdate();
3340

34-
acts.forEach((fn) => act(fn.bind(null, returnedValue)));
41+
if (mutation === true) {
42+
return;
43+
}
44+
45+
mutation(returnedValue);
46+
});
47+
values.push(returnedValue);
48+
}
3549

36-
ReactDOM.unmountComponentAtNode(div);
50+
unmountComponentAtNode(div);
3751

3852
if (errorThrown) {
3953
throw errorThrown;
4054
}
4155

42-
return returnedValue;
56+
return values;
4357
};

packages/fuselage-hooks/README.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ yarn test
4343
- [Parameters](#parameters-6)
4444
- [useMergedRefs](#usemergedrefs)
4545
- [Parameters](#parameters-7)
46-
- [useToggle](#usetoggle)
46+
- [useMutableCallback](#usemutablecallback)
4747
- [Parameters](#parameters-8)
48+
- [useToggle](#usetoggle)
49+
- [Parameters](#parameters-9)
4850

4951
### useClassName
5052

@@ -141,6 +143,16 @@ while receiving a forwared ref.
141143

142144
Returns **any** a merged callback ref
143145

146+
### useMutableCallback
147+
148+
Hook to create a stable callback from a mutable one.
149+
150+
#### Parameters
151+
152+
- `fn` **function (): any** the mutable callback
153+
154+
Returns **any** a stable callback
155+
144156
### useToggle
145157

146158
Hook to create a toggleable boolean state.

packages/fuselage-hooks/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"start": "rollup -c -w",
3030
"build": "rollup -c",
3131
"test": "jest",
32-
"lint": "eslint src",
32+
"lint": "eslint src tests",
3333
"lint-staged": "lint-staged",
3434
"docs": "documentation readme src/index.js --section='API Reference' --readme-file README.md"
3535
},

packages/fuselage-hooks/src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export * from './useDebouncedCallback';
66
export * from './useExclusiveBooleanProps';
77
export * from './useMediaQuery';
88
export * from './useMergedRefs';
9+
export * from './useMutableCallback';
910
export * from './useToggle';
1011
export * from './useUniqueId';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @flow
2+
3+
import { useCallback, useRef } from 'react';
4+
5+
/**
6+
* Hook to create a stable callback from a mutable one.
7+
*
8+
* @param fn the mutable callback
9+
* @return a stable callback
10+
*/
11+
export const useMutableCallback = (fn: (...args : any[]) => any) => {
12+
const fnRef = useRef(fn);
13+
fnRef.current = fn;
14+
15+
return useCallback((...args: any[]) => fnRef.current && (0, fnRef.current)(...args), []);
16+
};
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,42 @@
1-
import { testHook } from '../.jest/helpers';
1+
import { runHooks } from '../.jest/helpers';
22
import { useClassName } from '../src';
33

44
describe('useClassName hook', () => {
55
const componentClassName = 'component';
66

77
it('accepts only the component className', () => {
8-
const newClassName = testHook(() => useClassName(componentClassName));
8+
const [newClassName] = runHooks(() => useClassName(componentClassName));
99
expect(newClassName).toEqual(componentClassName);
1010
});
1111

1212
it('composes with a true-valued boolean modifier', () => {
13-
const newClassName = testHook(() => useClassName(componentClassName, { a: true }));
13+
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: true }));
1414
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a`);
1515
});
1616

1717
it('does not compose with a false-valued boolean modifier', () => {
18-
const newClassName = testHook(() => useClassName(componentClassName, { a: false }));
18+
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: false }));
1919
expect(newClassName).toEqual(componentClassName);
2020
});
2121

2222
it('composes with a non-boolean modifier', () => {
23-
const newClassName = testHook(() => useClassName(componentClassName, { a: 'b' }));
23+
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: 'b' }));
2424
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a-b`);
2525
});
2626

2727
it('appends an arbitrary amount of additional classNames', () => {
2828
const classNames = new Array(5).fill(undefined).map((i) => `class-${ i }`);
29-
const newClassName = testHook(() => useClassName(componentClassName, {}, ...classNames));
29+
const [newClassName] = runHooks(() => useClassName(componentClassName, {}, ...classNames));
3030
expect(newClassName).toEqual(`${ componentClassName } ${ classNames.join(' ') }`);
3131
});
3232

3333
it('formats a modifier name from camelCase to kebab-case', () => {
34-
const newClassName = testHook(() => useClassName(componentClassName, { camelCaseModifier: true }));
34+
const [newClassName] = runHooks(() => useClassName(componentClassName, { camelCaseModifier: true }));
3535
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--camel-case-modifier`);
3636
});
3737

3838
it('formats a modifier value from camelCase to kebab-case', () => {
39-
const newClassName = testHook(() => useClassName(componentClassName, { a: 'camelCaseValue' }));
39+
const [newClassName] = runHooks(() => useClassName(componentClassName, { a: 'camelCaseValue' }));
4040
expect(newClassName).toEqual(`${ componentClassName } ${ componentClassName }--a-camel-case-value`);
4141
});
4242
});

packages/fuselage-hooks/tests/useDebouncedCallback.spec.js

+11-52
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { useState } from 'react';
2-
3-
import { testHook } from '../.jest/helpers';
1+
import { runHooks } from '../.jest/helpers';
42
import { useDebouncedCallback } from '../src';
53

64
describe('useDebouncedCallback hook', () => {
@@ -13,7 +11,7 @@ describe('useDebouncedCallback hook', () => {
1311
});
1412

1513
it('returns a debounced callback', () => {
16-
const debouncedCallback = testHook(() => useDebouncedCallback(fn, delay));
14+
const [debouncedCallback] = runHooks(() => useDebouncedCallback(fn, delay));
1715
expect(debouncedCallback).toBeInstanceOf(Function);
1816
expect(debouncedCallback.flush).toBeInstanceOf(Function);
1917
expect(debouncedCallback.cancel).toBeInstanceOf(Function);
@@ -24,69 +22,30 @@ describe('useDebouncedCallback hook', () => {
2422
});
2523

2624
it('returns the same callback if deps don\'t change', () => {
27-
let callbackA;
28-
let callbackB;
29-
let setDummy;
30-
31-
testHook(
32-
() => {
33-
[, setDummy] = useState(0);
34-
return useDebouncedCallback(fn, delay, []);
35-
},
36-
(returnedValue) => {
37-
callbackA = returnedValue;
38-
setDummy((dep) => dep + 1);
39-
},
40-
(returnedValue) => {
41-
callbackB = returnedValue;
42-
}
43-
);
44-
25+
const [callbackA, callbackB] = runHooks(() => useDebouncedCallback(fn, delay, []), [true]);
4526
expect(callbackA).toBe(callbackB);
4627
});
4728

4829
it('returns another callback if deps change', () => {
49-
let callbackA;
50-
let callbackB;
51-
let dep;
52-
let setDep;
30+
let dep = Symbol();
5331

54-
testHook(
32+
const [callbackA, , callbackB] = runHooks(() => useDebouncedCallback(fn, delay, [dep]), [
5533
() => {
56-
[dep, setDep] = useState(0);
57-
return useDebouncedCallback(fn, delay, [dep]);
34+
dep = Symbol();
5835
},
59-
(returnedValue) => {
60-
callbackA = returnedValue;
61-
setDep((dep) => dep + 1);
62-
},
63-
(returnedValue) => {
64-
callbackB = returnedValue;
65-
}
66-
);
36+
]);
6737

6838
expect(callbackA).not.toBe(callbackB);
6939
});
7040

7141
it('returns another callback if delay change', () => {
72-
let callbackA;
73-
let callbackB;
74-
let delay;
75-
let setDelay;
42+
let delay = 0;
7643

77-
testHook(
44+
const [callbackA, callbackB] = runHooks(() => useDebouncedCallback(fn, delay, []), [
7845
() => {
79-
[delay, setDelay] = useState(0);
80-
return useDebouncedCallback(fn, delay, []);
81-
},
82-
(returnedValue) => {
83-
callbackA = returnedValue;
84-
setDelay((delay) => delay + 1);
46+
delay = 1;
8547
},
86-
(returnedValue) => {
87-
callbackB = returnedValue;
88-
}
89-
);
48+
]);
9049

9150
expect(callbackA).not.toBe(callbackB);
9251
});
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from 'react';
22

3-
import { testHook } from '../.jest/helpers';
3+
import { runHooks } from '../.jest/helpers';
44
import { useDebouncedUpdates, useDebouncedReducer, useDebouncedState } from '../src';
55

66
describe('useDebouncedUpdates hook', () => {
@@ -11,23 +11,10 @@ describe('useDebouncedUpdates hook', () => {
1111
});
1212

1313
it('returns a debounced state updater', () => {
14-
let valueA;
15-
let valueB;
16-
let valueC;
17-
const [, debouncedSetValue] = testHook(
18-
() => useDebouncedUpdates(useState(0), delay),
19-
([value, debouncedSetValue]) => {
20-
valueA = value;
21-
debouncedSetValue((value) => value + 1);
22-
},
23-
([value]) => {
24-
valueB = value;
25-
jest.runAllTimers();
26-
},
27-
([value]) => {
28-
valueC = value;
29-
}
30-
);
14+
const [[valueA, debouncedSetValue], [valueB], [valueC]] = runHooks(() => useDebouncedUpdates(useState(0), delay), [
15+
([, setValue]) => setValue((value) => value + 1),
16+
() => jest.runAllTimers(),
17+
]);
3118

3219
expect(debouncedSetValue).toBeInstanceOf(Function);
3320
expect(debouncedSetValue.flush).toBeInstanceOf(Function);
@@ -39,55 +26,46 @@ describe('useDebouncedUpdates hook', () => {
3926

4027
describe('useDebouncedReducer hook', () => {
4128
it('is a debounced state updater', () => {
42-
const initialState = {};
43-
const newState = {};
29+
const initialState = Symbol();
30+
const newState = Symbol();
4431
const reducer = jest.fn(() => newState);
4532
const initializerArg = initialState;
4633
const initializer = jest.fn((state) => state);
47-
let stateA;
48-
let stateB;
49-
testHook(
50-
() => useDebouncedReducer(reducer, initializerArg, initializer, delay),
51-
([, dispatch]) => {
52-
dispatch();
53-
},
54-
([state]) => {
55-
stateA = state;
56-
jest.runAllTimers();
57-
},
58-
([state]) => {
59-
stateB = state;
60-
}
61-
);
34+
35+
const [
36+
[stateA], [stateB], [stateC],
37+
] = runHooks(() => useDebouncedReducer(reducer, initializerArg, initializer, delay), [
38+
([, dispatch]) => dispatch(),
39+
() => jest.runAllTimers(),
40+
]);
6241

6342
expect(reducer).toHaveBeenCalledWith(initialState, undefined);
6443
expect(initializer).toHaveBeenCalledWith(initializerArg);
6544
expect(stateA).toBe(initialState);
66-
expect(stateB).toBe(newState);
45+
expect(stateB).toBe(initialState);
46+
expect(stateC).toBe(newState);
6747
});
6848
});
6949

7050
describe('useDebouncedState hook', () => {
7151
it('is a debounced state updater', () => {
72-
const initialValue = {};
73-
const newValue = {};
74-
let valueA;
75-
let valueB;
76-
testHook(
77-
() => useDebouncedState(initialValue, delay),
52+
const initialValue = Symbol();
53+
const newValue = Symbol();
54+
55+
const [
56+
[valueA], [valueB], [valueC],
57+
] = runHooks(() => useDebouncedState(initialValue, delay), [
7858
([, setValue]) => {
7959
setValue(newValue);
8060
},
81-
([state]) => {
82-
valueA = state;
61+
() => {
8362
jest.runAllTimers();
8463
},
85-
([state]) => {
86-
valueB = state;
87-
}
88-
);
64+
]);
65+
8966
expect(valueA).toBe(initialValue);
90-
expect(valueB).toBe(newValue);
67+
expect(valueB).toBe(initialValue);
68+
expect(valueC).toBe(newValue);
9169
});
9270
});
9371
});

0 commit comments

Comments
 (0)