Skip to content

Commit ca620a0

Browse files
authored
feat: New hooks and server-side compatibility (#203)
1 parent 5ab9f6a commit ca620a0

26 files changed

+586
-74
lines changed

packages/fuselage-hooks/.flowconfig

+1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ flow-typed
88
[lints]
99

1010
[options]
11+
esproposal.optional_chaining=enable
1112

1213
[strict]

packages/fuselage-hooks/.jest/helpers.js

+14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useReducer, Component, createElement } from 'react';
22
import ReactDOM, { render, unmountComponentAtNode } from 'react-dom';
3+
import { renderToString } from 'react-dom/server';
34
import { act } from 'react-dom/test-utils';
45

56
export const runHooks = (fn, mutations = []) => {
@@ -55,3 +56,16 @@ export const runHooks = (fn, mutations = []) => {
5556

5657
return values;
5758
};
59+
60+
export const runHooksOnServer = (fn) => {
61+
let returnedValue;
62+
63+
function FunctionalComponent() {
64+
returnedValue = fn();
65+
return null;
66+
}
67+
68+
renderToString(<FunctionalComponent />);
69+
70+
return returnedValue;
71+
};

packages/fuselage-hooks/README.md

+54-15
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,43 @@ yarn test
2727

2828
#### Table of Contents
2929

30-
- [useClassName](#useclassname)
30+
- [useAutoFocus](#useautofocus)
3131
- [Parameters](#parameters)
32-
- [useDebouncedUpdates](#usedebouncedupdates)
32+
- [useClassName](#useclassname)
3333
- [Parameters](#parameters-1)
34-
- [useDebouncedReducer](#usedebouncedreducer)
34+
- [useDebouncedUpdates](#usedebouncedupdates)
3535
- [Parameters](#parameters-2)
36-
- [useDebouncedState](#usedebouncedstate)
36+
- [useDebouncedReducer](#usedebouncedreducer)
3737
- [Parameters](#parameters-3)
38-
- [useDebouncedCallback](#usedebouncedcallback)
38+
- [useDebouncedState](#usedebouncedstate)
3939
- [Parameters](#parameters-4)
40-
- [useExclusiveBooleanProps](#useexclusivebooleanprops)
40+
- [useDebouncedCallback](#usedebouncedcallback)
4141
- [Parameters](#parameters-5)
42-
- [useMediaQuery](#usemediaquery)
42+
- [useDebouncedValue](#usedebouncedvalue)
4343
- [Parameters](#parameters-6)
44-
- [useMergedRefs](#usemergedrefs)
44+
- [useLazyRef](#uselazyref)
4545
- [Parameters](#parameters-7)
46-
- [useMutableCallback](#usemutablecallback)
46+
- [useMediaQuery](#usemediaquery)
4747
- [Parameters](#parameters-8)
48-
- [useToggle](#usetoggle)
48+
- [useMergedRefs](#usemergedrefs)
4949
- [Parameters](#parameters-9)
50+
- [useMutableCallback](#usemutablecallback)
51+
- [Parameters](#parameters-10)
52+
- [useSafely](#usesafely)
53+
- [Parameters](#parameters-11)
54+
- [useToggle](#usetoggle)
55+
- [Parameters](#parameters-12)
56+
57+
### useAutoFocus
58+
59+
Hook to automatically request focus for an DOM element.
60+
61+
#### Parameters
62+
63+
- `isFocused` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** if true, the focus will be requested (optional, default `true`)
64+
- `options` **FocusOptions** options of the focus request
65+
66+
Returns **any** the ref which holds the element
5067

5168
### useClassName
5269

@@ -110,17 +127,26 @@ Hook to memoize a debounced version of a callback.
110127

111128
Returns **function (): any** a memoized and debounced callback
112129

113-
### useExclusiveBooleanProps
130+
### useDebouncedValue
114131

115-
Hook for asserting mutually exclusive boolean props. Useful for components that use boolean props
116-
to choose styling variants.
132+
Hook to keep a debounced reference of a value.
117133

118134
#### Parameters
119135

120-
- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** the mutually exclusive boolean props
136+
- `value` **any** the value to be debounced
137+
- `delay` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** the number of milliseconds to delay
138+
139+
Returns **any** a debounced value
140+
141+
### useLazyRef
121142

143+
Hook equivalent to useRef, but with a lazy initialization for computed value.
144+
145+
#### Parameters
122146

123-
- Throws **any** if two or more booleans props are set as true
147+
- `initializer` **function (): T** the function the computes the ref value
148+
149+
Returns **any** the ref
124150

125151
### useMediaQuery
126152

@@ -153,6 +179,19 @@ Hook to create a stable callback from a mutable one.
153179

154180
Returns **any** a stable callback
155181

182+
### useSafely
183+
184+
Hook that wraps pairs of state and updater to provide a new updater which
185+
can be safe and asynchronically called even after the component unmounted.
186+
187+
#### Parameters
188+
189+
- `pair` **\[any, function (): any]** the state and updater pair which will be patched
190+
- `pair.0` the state value
191+
- `pair.1` the state updater function
192+
193+
Returns **any** a state value and safe updater pair
194+
156195
### useToggle
157196

158197
Hook to create a toggleable boolean state.

packages/fuselage-hooks/src/helpers.js

+2
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ export const debounce = (fn: (...Array<any>) => any, delay: number) => {
2424

2525
return f;
2626
};
27+
28+
export const isRunningOnBrowser = typeof window !== 'undefined' && window.document;

packages/fuselage-hooks/src/index.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// @flow
22

3+
export * from './useAutoFocus';
34
export * from './useClassName';
45
export * from './useDebouncedUpdates';
56
export * from './useDebouncedCallback';
6-
export * from './useExclusiveBooleanProps';
7+
export * from './useDebouncedValue';
8+
export * from './useLazyRef';
79
export * from './useMediaQuery';
810
export * from './useMergedRefs';
911
export * from './useMutableCallback';
12+
export * from './useSafely';
1013
export * from './useToggle';
1114
export * from './useUniqueId';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// @flow
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
type FocusOptions = {
6+
preventScroll?: boolean,
7+
} | typeof undefined;
8+
9+
/**
10+
* Hook to automatically request focus for an DOM element.
11+
*
12+
* @param isFocused if true, the focus will be requested
13+
* @param options options of the focus request
14+
* @return the ref which holds the element
15+
*/
16+
export const useAutoFocus = (isFocused: boolean = true, options: FocusOptions) => {
17+
const elementRef = useRef<?HTMLElement>();
18+
19+
useEffect(() => {
20+
if (isFocused && elementRef.current) {
21+
elementRef.current.focus(options);
22+
}
23+
}, [elementRef, isFocused]);
24+
25+
return elementRef;
26+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// @flow
2+
3+
import { useEffect, useState } from 'react';
4+
5+
/**
6+
* Hook to keep a debounced reference of a value.
7+
*
8+
* @param value the value to be debounced
9+
* @param delay the number of milliseconds to delay
10+
* @return a debounced value
11+
*/
12+
export const useDebouncedValue = (value: any, delay: number) => {
13+
const [debouncedValue, setDebouncedValue] = useState(value);
14+
15+
useEffect(() => {
16+
const timer = setTimeout(() => {
17+
setDebouncedValue(value);
18+
}, delay);
19+
20+
return () => {
21+
clearTimeout(timer);
22+
};
23+
}, [value, delay]);
24+
25+
return debouncedValue;
26+
};

packages/fuselage-hooks/src/useExclusiveBooleanProps.js

-16
This file was deleted.
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @flow
2+
3+
import { createRef, useState } from 'react';
4+
5+
/**
6+
* Hook equivalent to useRef, but with a lazy initialization for computed value.
7+
*
8+
* @param initializer the function the computes the ref value
9+
* @return the ref
10+
*/
11+
export const useLazyRef = <T>(initializer: () => T) =>
12+
useState(() => {
13+
const ref = createRef<T>();
14+
ref.current = initializer();
15+
return ref;
16+
})[0];

packages/fuselage-hooks/src/useMediaQuery.js

+11-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// @flow
22

3-
import { useLayoutEffect, useState } from 'react';
3+
import { useEffect, useState } from 'react';
4+
5+
import { isRunningOnBrowser } from './helpers';
46

57
/**
68
* Hook to listen to a media query.
@@ -10,36 +12,30 @@ import { useLayoutEffect, useState } from 'react';
1012
*/
1113
export const useMediaQuery = (query: string): bool => {
1214
const [matches, setMatches] = useState(() => {
13-
if (!query) {
15+
if (!query || !isRunningOnBrowser) {
1416
return false;
1517
}
1618

1719
const { matches } = window.matchMedia(query);
1820
return !!matches;
1921
});
2022

21-
useLayoutEffect(() => {
22-
if (!query) {
23+
useEffect(() => {
24+
if (!query || !isRunningOnBrowser) {
2325
return;
2426
}
2527

26-
let mounted = true;
27-
const mql = window.matchMedia(query);
28+
const mediaQueryListener = window.matchMedia(query);
29+
setMatches(mediaQueryListener.matches);
2830

2931
const handleChange = () => {
30-
if (!mounted) {
31-
return;
32-
}
33-
34-
setMatches(!!mql.matches);
32+
setMatches(!!mediaQueryListener.matches);
3533
};
3634

37-
mql.addListener(handleChange);
38-
setMatches(mql.matches);
35+
mediaQueryListener.addListener(handleChange);
3936

4037
return () => {
41-
mounted = false;
42-
mql.removeListener(handleChange);
38+
mediaQueryListener.removeListener(handleChange);
4339
};
4440
}, [query]);
4541

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @flow
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
import { useMutableCallback } from './useMutableCallback';
6+
7+
/**
8+
* Hook that wraps pairs of state and updater to provide a new updater which
9+
* can be safe and asynchronically called even after the component unmounted.
10+
*
11+
* @param pair - the state and updater pair which will be patched
12+
* @param pair.0 - the state value
13+
* @param pair.1 - the state updater function
14+
* @return a state value and safe updater pair
15+
*/
16+
export const useSafely = ([state, updater]: [any, () => any]) => {
17+
const mountedRef = useRef(true);
18+
19+
useEffect(() => {
20+
mountedRef.current = true;
21+
22+
return () => {
23+
mountedRef.current = false;
24+
};
25+
});
26+
27+
const safeUpdater = useMutableCallback((...args) => {
28+
if (!mountedRef.current) {
29+
return;
30+
}
31+
32+
updater(...args);
33+
});
34+
35+
return [state, safeUpdater];
36+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useState } from 'react';
2+
3+
import { runHooks } from '../.jest/helpers';
4+
import { useAutoFocus } from '../src';
5+
6+
describe('useAutoFocus hook', () => {
7+
it('returns a ref', () => {
8+
const [ref] = runHooks(() => useAutoFocus());
9+
10+
expect(ref).toMatchObject({ current: undefined });
11+
});
12+
13+
it('invokes focus', () => {
14+
const focus = jest.fn();
15+
runHooks(() => useAutoFocus(), [
16+
(ref) => {
17+
ref.current = { focus };
18+
},
19+
]);
20+
21+
expect(focus).toHaveBeenCalledTimes(1);
22+
});
23+
24+
it('does not invoke focus if isFocused is false', () => {
25+
const focus = jest.fn();
26+
runHooks(() => useAutoFocus(false), [
27+
(ref) => {
28+
ref.current = { focus };
29+
},
30+
]);
31+
32+
expect(focus).toHaveBeenCalledTimes(0);
33+
});
34+
35+
it('invokes focus if isFocused is toggled', () => {
36+
const focus = jest.fn();
37+
runHooks(() => {
38+
const [isFocused, setFocused] = useState(false);
39+
return [useAutoFocus(isFocused), setFocused];
40+
}, [
41+
([ref]) => {
42+
ref.current = { focus };
43+
},
44+
([, setFocused]) => {
45+
setFocused(true);
46+
},
47+
]);
48+
49+
expect(focus).toHaveBeenCalledTimes(1);
50+
});
51+
});

0 commit comments

Comments
 (0)