@uniflowed/hooks 0.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +60 -0
- package/internal/async.js +77 -0
- package/internal/browser.js +145 -0
- package/internal/element.js +186 -0
- package/internal/lifecycle.js +100 -0
- package/internal/state.js +159 -0
- package/internal/timing.js +116 -0
- package/package.json +26 -0
package/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks`: the hooks a React application writes anyway.
|
|
4
|
+
//
|
|
5
|
+
// Not a large collection. Every hook here is one that people write by hand in
|
|
6
|
+
// every project and get subtly wrong in the same way each time — a timer that
|
|
7
|
+
// calls a stale closure, a subscription re-established on every keystroke, a
|
|
8
|
+
// slow request overwriting a fast one, persisted state that differs between the
|
|
9
|
+
// server render and the first paint.
|
|
10
|
+
//
|
|
11
|
+
// # Prerendering is the constraint that shapes the surface
|
|
12
|
+
//
|
|
13
|
+
// uf prerenders every static route, so each of these runs once where there is
|
|
14
|
+
// no `window`. The browser hooks are built on `useSyncExternalStore`, which
|
|
15
|
+
// takes the server's value as a separate argument — so what a prerender sees is
|
|
16
|
+
// *stated* rather than being whatever a `typeof window` check fell through to,
|
|
17
|
+
// and React reads the value when it commits rather than when it renders, which
|
|
18
|
+
// is what stops a media query that changes mid-render from tearing.
|
|
19
|
+
//
|
|
20
|
+
// Where there is no honest default, the caller supplies one: a page that hides
|
|
21
|
+
// its sidebar under 48rem wants `false` on the server and one that renders a
|
|
22
|
+
// mobile menu wants `true`, and a library cannot know which.
|
|
23
|
+
|
|
24
|
+
export type { Async } from "./internal/async.js";
|
|
25
|
+
|
|
26
|
+
export { useAsync } from "./internal/async.js";
|
|
27
|
+
export {
|
|
28
|
+
useIsomorphicLayoutEffect,
|
|
29
|
+
useMount,
|
|
30
|
+
useMounted,
|
|
31
|
+
usePrevious,
|
|
32
|
+
useRerender,
|
|
33
|
+
useStableCallback,
|
|
34
|
+
useUnmount,
|
|
35
|
+
} from "./internal/lifecycle.js";
|
|
36
|
+
export {
|
|
37
|
+
useDebouncedCallback,
|
|
38
|
+
useDebouncedValue,
|
|
39
|
+
useInterval,
|
|
40
|
+
useThrottledCallback,
|
|
41
|
+
useTimeout,
|
|
42
|
+
} from "./internal/timing.js";
|
|
43
|
+
export {
|
|
44
|
+
useDocumentVisible,
|
|
45
|
+
useMediaQuery,
|
|
46
|
+
useOnline,
|
|
47
|
+
usePrefersReducedMotion,
|
|
48
|
+
usePreferredColorScheme,
|
|
49
|
+
useWindowSize,
|
|
50
|
+
} from "./internal/browser.js";
|
|
51
|
+
export {
|
|
52
|
+
useClickOutside,
|
|
53
|
+
useElementRef,
|
|
54
|
+
useElementSize,
|
|
55
|
+
useEventListener,
|
|
56
|
+
useFocusWithin,
|
|
57
|
+
useHover,
|
|
58
|
+
useIntersecting,
|
|
59
|
+
} from "./internal/element.js";
|
|
60
|
+
export { useCounter, useStorage, useToggle } from "./internal/state.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Running a promise from a component.
|
|
4
|
+
//
|
|
5
|
+
// Two bugs a hand-written version has, and only one of them is a warning:
|
|
6
|
+
// setting state after the component has gone, and a slow first request
|
|
7
|
+
// overwriting a fast second one. The second is the dangerous one — it puts a
|
|
8
|
+
// wrong answer on screen and nothing says so.
|
|
9
|
+
//
|
|
10
|
+
// Both are fixed by the effect's own cleanup rather than by a ref: the effect
|
|
11
|
+
// that started a request is the thing that knows it has been superseded,
|
|
12
|
+
// because React runs its cleanup before running it again. That is the shape
|
|
13
|
+
// React's own documentation uses, and it means there is no "latest" anything
|
|
14
|
+
// to keep in a ref and no generation counter to keep in step.
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useState } from "@uniflowed/react";
|
|
17
|
+
|
|
18
|
+
/** What an in-flight, settled or failed call looks like. */
|
|
19
|
+
export type Async<T> = {|
|
|
20
|
+
readonly value: T | null,
|
|
21
|
+
readonly error: Error | null,
|
|
22
|
+
readonly pending: boolean,
|
|
23
|
+
/** Run it again, keeping whatever is on screen until the new value lands. */
|
|
24
|
+
readonly reload: () => void,
|
|
25
|
+
|};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Call `body` when `deps` change, and report what happened.
|
|
29
|
+
*
|
|
30
|
+
* The previous value stays on screen while a reload is in flight, because
|
|
31
|
+
* blanking the page to show a spinner every time a filter changes is worse
|
|
32
|
+
* than showing slightly stale data for a moment. `pending` says which it is.
|
|
33
|
+
*/
|
|
34
|
+
export function useAsync<T>(body: () => Promise<T>, deps: $ReadOnlyArray<mixed>): Async<T> {
|
|
35
|
+
const [state, setState] = useState<{|
|
|
36
|
+
value: T | null,
|
|
37
|
+
error: Error | null,
|
|
38
|
+
pending: boolean,
|
|
39
|
+
|}>({ value: null, error: null, pending: true });
|
|
40
|
+
|
|
41
|
+
// Changing this is what re-runs the effect, so `reload` is a state change
|
|
42
|
+
// rather than a function the effect has to be told about.
|
|
43
|
+
const [attempt, setAttempt] = useState(0);
|
|
44
|
+
const reload = useCallback(() => setAttempt((current) => current + 1), []);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Set when this effect is superseded — by a dependency change, a reload,
|
|
48
|
+
// or an unmount. React runs the cleanup before the next run, so the
|
|
49
|
+
// request that is no longer wanted knows not to write.
|
|
50
|
+
let ignore = false;
|
|
51
|
+
setState((current) => ({ ...current, pending: true }));
|
|
52
|
+
|
|
53
|
+
body().then(
|
|
54
|
+
(value) => {
|
|
55
|
+
if (!ignore) {
|
|
56
|
+
setState({ value, error: null, pending: false });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
(thrown) => {
|
|
60
|
+
if (!ignore) {
|
|
61
|
+
setState({
|
|
62
|
+
value: null,
|
|
63
|
+
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
|
|
64
|
+
pending: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
ignore = true;
|
|
72
|
+
};
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [...deps, attempt]);
|
|
75
|
+
|
|
76
|
+
return { ...state, reload };
|
|
77
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Reading the browser, safely on a server.
|
|
4
|
+
//
|
|
5
|
+
// uf prerenders every static route, so each of these runs once where there is
|
|
6
|
+
// no `window`. `useSyncExternalStore` is what makes that correct rather than
|
|
7
|
+
// guarded: it takes a server snapshot as a separate argument, so the value
|
|
8
|
+
// used during prerender is stated rather than being whatever a `typeof window`
|
|
9
|
+
// check happened to fall through to. It also means React reads the value at
|
|
10
|
+
// the moment it commits, which is what stops a media query changing between
|
|
11
|
+
// render and paint from tearing.
|
|
12
|
+
|
|
13
|
+
import { useCallback, useSyncExternalStore } from "@uniflowed/react";
|
|
14
|
+
|
|
15
|
+
/** Whether there is a document to read at all. */
|
|
16
|
+
function inBrowser(): boolean {
|
|
17
|
+
return typeof globalThis.document !== "undefined";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The window these hooks listen to.
|
|
22
|
+
*
|
|
23
|
+
* In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
|
|
24
|
+
* looks correct. It is not correct anywhere a document has been installed onto
|
|
25
|
+
* another host's global — which is every uf test process, where `globalThis` is
|
|
26
|
+
* Node's and has no `addEventListener` at all. Ask the window for its own
|
|
27
|
+
* methods and both cases work.
|
|
28
|
+
*/
|
|
29
|
+
function windowOf(): any {
|
|
30
|
+
return globalThis.window ?? globalThis;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a media query matches.
|
|
35
|
+
*
|
|
36
|
+
* `serverValue` is what a prerender should assume, and it has no honest
|
|
37
|
+
* default — a page that hides a sidebar under 48rem wants `false` on the
|
|
38
|
+
* server, and one that renders a mobile menu wants `true`. So the caller says.
|
|
39
|
+
*/
|
|
40
|
+
export function useMediaQuery(query: string, serverValue: boolean = false): boolean {
|
|
41
|
+
const subscribe = useCallback(
|
|
42
|
+
(notify: () => void) => {
|
|
43
|
+
if (!inBrowser() || typeof windowOf().matchMedia !== "function") {
|
|
44
|
+
return () => {};
|
|
45
|
+
}
|
|
46
|
+
const list = windowOf().matchMedia(query);
|
|
47
|
+
list.addEventListener("change", notify);
|
|
48
|
+
return () => list.removeEventListener("change", notify);
|
|
49
|
+
},
|
|
50
|
+
[query],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return useSyncExternalStore(
|
|
54
|
+
subscribe,
|
|
55
|
+
() =>
|
|
56
|
+
inBrowser() && typeof windowOf().matchMedia === "function"
|
|
57
|
+
? windowOf().matchMedia(query).matches
|
|
58
|
+
: serverValue,
|
|
59
|
+
() => serverValue,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The reader's colour-scheme preference. */
|
|
64
|
+
export function usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" {
|
|
65
|
+
return useMediaQuery("(prefers-color-scheme: dark)", serverValue === "dark") ? "dark" : "light";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether the reader has asked for less motion. */
|
|
69
|
+
export function usePrefersReducedMotion(serverValue: boolean = false): boolean {
|
|
70
|
+
return useMediaQuery("(prefers-reduced-motion: reduce)", serverValue);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether the browser thinks it is online. */
|
|
74
|
+
export function useOnline(serverValue: boolean = true): boolean {
|
|
75
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
76
|
+
if (!inBrowser()) {
|
|
77
|
+
return () => {};
|
|
78
|
+
}
|
|
79
|
+
const win = windowOf();
|
|
80
|
+
win.addEventListener("online", notify);
|
|
81
|
+
win.addEventListener("offline", notify);
|
|
82
|
+
return () => {
|
|
83
|
+
win.removeEventListener("online", notify);
|
|
84
|
+
win.removeEventListener("offline", notify);
|
|
85
|
+
};
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return useSyncExternalStore(
|
|
89
|
+
subscribe,
|
|
90
|
+
() => (inBrowser() ? (windowOf().navigator?.onLine ?? true) : serverValue),
|
|
91
|
+
() => serverValue,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether the document is the one the reader is looking at. */
|
|
96
|
+
export function useDocumentVisible(serverValue: boolean = true): boolean {
|
|
97
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
98
|
+
if (!inBrowser()) {
|
|
99
|
+
return () => {};
|
|
100
|
+
}
|
|
101
|
+
globalThis.document.addEventListener("visibilitychange", notify);
|
|
102
|
+
return () => globalThis.document.removeEventListener("visibilitychange", notify);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
return useSyncExternalStore(
|
|
106
|
+
subscribe,
|
|
107
|
+
() => (inBrowser() ? globalThis.document.visibilityState !== "hidden" : serverValue),
|
|
108
|
+
() => serverValue,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The size of the viewport. */
|
|
113
|
+
export function useWindowSize(serverValue?: {|
|
|
114
|
+
readonly width: number,
|
|
115
|
+
readonly height: number,
|
|
116
|
+
|}): {|
|
|
117
|
+
readonly width: number,
|
|
118
|
+
readonly height: number,
|
|
119
|
+
|} {
|
|
120
|
+
const fallback = serverValue ?? { width: 0, height: 0 };
|
|
121
|
+
|
|
122
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
123
|
+
if (!inBrowser()) {
|
|
124
|
+
return () => {};
|
|
125
|
+
}
|
|
126
|
+
const win = windowOf();
|
|
127
|
+
win.addEventListener("resize", notify);
|
|
128
|
+
return () => win.removeEventListener("resize", notify);
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
// A string snapshot, because `useSyncExternalStore` compares snapshots by
|
|
132
|
+
// identity: returning a fresh object every time would re-render on every
|
|
133
|
+
// check, which is an infinite loop React reports rather than tolerates.
|
|
134
|
+
const packed = useSyncExternalStore(
|
|
135
|
+
subscribe,
|
|
136
|
+
() =>
|
|
137
|
+
inBrowser()
|
|
138
|
+
? `${windowOf().innerWidth}x${windowOf().innerHeight}`
|
|
139
|
+
: `${fallback.width}x${fallback.height}`,
|
|
140
|
+
() => `${fallback.width}x${fallback.height}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const [width, height] = packed.split("x");
|
|
144
|
+
return { width: Number(width), height: Number(height) };
|
|
145
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Watching a node.
|
|
4
|
+
//
|
|
5
|
+
// Each of these takes a ref rather than returning one, so a component can put
|
|
6
|
+
// several on the same element and can hand the ref to something else as well.
|
|
7
|
+
// The listener is attached in a layout effect, so it is in place before the
|
|
8
|
+
// browser paints — a click that lands in the same frame as the mount is a real
|
|
9
|
+
// case on a touch screen.
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState } from "@uniflowed/react";
|
|
12
|
+
|
|
13
|
+
import { useIsomorphicLayoutEffect, useStableCallback } from "./lifecycle.js";
|
|
14
|
+
|
|
15
|
+
type Ref<T> = { current: T | null };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The window these hooks read constructors from.
|
|
19
|
+
*
|
|
20
|
+
* In a browser `globalThis` is the window; anywhere a document was installed
|
|
21
|
+
* onto another host's global it is not, and `globalThis.ResizeObserver` is
|
|
22
|
+
* undefined while `window.ResizeObserver` is there.
|
|
23
|
+
*/
|
|
24
|
+
function windowOf(): any {
|
|
25
|
+
return globalThis.window ?? globalThis;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Listen to an event on a target, cleaning up after itself.
|
|
30
|
+
*
|
|
31
|
+
* `target` may be a ref, an element, or a function returning either, so this
|
|
32
|
+
* covers the window, the document, and a node that does not exist yet at the
|
|
33
|
+
* time the hook is called.
|
|
34
|
+
*/
|
|
35
|
+
export function useEventListener<TEvent extends Event>(
|
|
36
|
+
target: Ref<EventTarget> | EventTarget | (() => EventTarget | null) | null,
|
|
37
|
+
name: string,
|
|
38
|
+
handler: (event: TEvent) => mixed,
|
|
39
|
+
options?: AddEventListenerOptions,
|
|
40
|
+
): void {
|
|
41
|
+
const stable = useStableCallback(handler);
|
|
42
|
+
const capture = options?.capture ?? false;
|
|
43
|
+
const passive = options?.passive;
|
|
44
|
+
const once = options?.once ?? false;
|
|
45
|
+
|
|
46
|
+
useIsomorphicLayoutEffect(() => {
|
|
47
|
+
const node = resolve(target);
|
|
48
|
+
if (node == null) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const listener = (event: Event) => stable(event as any);
|
|
52
|
+
node.addEventListener(name, listener, { capture, passive, once });
|
|
53
|
+
return () => node.removeEventListener(name, listener, { capture });
|
|
54
|
+
}, [target, name, stable, capture, passive, once]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Call `handler` when a press lands outside `ref`.
|
|
59
|
+
*
|
|
60
|
+
* `pointerdown` rather than `click`, because a menu that closes on click stays
|
|
61
|
+
* open for the whole press — and because a click whose press started inside
|
|
62
|
+
* the menu and ended outside it should not close it.
|
|
63
|
+
*/
|
|
64
|
+
export function useClickOutside(ref: Ref<HTMLElement>, handler: (event: Event) => mixed): void {
|
|
65
|
+
const stable = useStableCallback(handler);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (typeof globalThis.document === "undefined") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const listener = (event: Event) => {
|
|
72
|
+
const node = ref.current;
|
|
73
|
+
const target: any = event.target;
|
|
74
|
+
if (node != null && target != null && !node.contains(target)) {
|
|
75
|
+
stable(event);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
globalThis.document.addEventListener("pointerdown", listener);
|
|
79
|
+
return () => globalThis.document.removeEventListener("pointerdown", listener);
|
|
80
|
+
}, [ref, stable]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether the pointer is over the element. */
|
|
84
|
+
export function useHover(ref: Ref<HTMLElement>): boolean {
|
|
85
|
+
const [hovered, setHovered] = useState(false);
|
|
86
|
+
useEventListener(ref, "pointerenter", () => setHovered(true));
|
|
87
|
+
useEventListener(ref, "pointerleave", () => setHovered(false));
|
|
88
|
+
return hovered;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether focus is inside the element. */
|
|
92
|
+
export function useFocusWithin(ref: Ref<HTMLElement>): boolean {
|
|
93
|
+
const [within, setWithin] = useState(false);
|
|
94
|
+
useEventListener(ref, "focusin", () => setWithin(true));
|
|
95
|
+
useEventListener(ref, "focusout", () => setWithin(false));
|
|
96
|
+
return within;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The element's size, as the browser measures it.
|
|
101
|
+
*
|
|
102
|
+
* A `ResizeObserver` rather than a window resize listener, because an element
|
|
103
|
+
* changes size when its content changes, when a sibling grows, and when a
|
|
104
|
+
* container query fires — none of which resizes the window.
|
|
105
|
+
*/
|
|
106
|
+
export function useElementSize(ref: Ref<HTMLElement>): {|
|
|
107
|
+
readonly width: number,
|
|
108
|
+
readonly height: number,
|
|
109
|
+
|} {
|
|
110
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
111
|
+
|
|
112
|
+
useIsomorphicLayoutEffect(() => {
|
|
113
|
+
const node = ref.current;
|
|
114
|
+
if (node == null || typeof windowOf().ResizeObserver !== "function") {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const observer = new (windowOf().ResizeObserver)((entries) => {
|
|
118
|
+
const entry = entries[0];
|
|
119
|
+
if (entry == null) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const box = entry.contentRect;
|
|
123
|
+
// Only on a real change: an observer that fires with the same numbers
|
|
124
|
+
// would re-render forever.
|
|
125
|
+
setSize((current) =>
|
|
126
|
+
current.width === box.width && current.height === box.height
|
|
127
|
+
? current
|
|
128
|
+
: { width: box.width, height: box.height },
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
observer.observe(node);
|
|
132
|
+
return () => observer.disconnect();
|
|
133
|
+
}, [ref]);
|
|
134
|
+
|
|
135
|
+
return size;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether the element is in the viewport. */
|
|
139
|
+
export function useIntersecting(
|
|
140
|
+
ref: Ref<HTMLElement>,
|
|
141
|
+
options?: {| readonly rootMargin?: string, readonly threshold?: number |},
|
|
142
|
+
): boolean {
|
|
143
|
+
const [intersecting, setIntersecting] = useState(false);
|
|
144
|
+
const rootMargin = options?.rootMargin;
|
|
145
|
+
const threshold = options?.threshold;
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const node = ref.current;
|
|
149
|
+
if (node == null || typeof windowOf().IntersectionObserver !== "function") {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const observer = new (windowOf().IntersectionObserver)(
|
|
153
|
+
(entries) => {
|
|
154
|
+
const entry = entries[0];
|
|
155
|
+
if (entry != null) {
|
|
156
|
+
setIntersecting(entry.isIntersecting);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{ rootMargin, threshold },
|
|
160
|
+
);
|
|
161
|
+
observer.observe(node);
|
|
162
|
+
return () => observer.disconnect();
|
|
163
|
+
}, [ref, rootMargin, threshold]);
|
|
164
|
+
|
|
165
|
+
return intersecting;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolve(
|
|
169
|
+
target: Ref<EventTarget> | EventTarget | (() => EventTarget | null) | null,
|
|
170
|
+
): EventTarget | null {
|
|
171
|
+
if (target == null) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (typeof target === "function") {
|
|
175
|
+
return target();
|
|
176
|
+
}
|
|
177
|
+
if (typeof (target as any).addEventListener === "function") {
|
|
178
|
+
return target as any;
|
|
179
|
+
}
|
|
180
|
+
return (target as any).current ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A ref for one of the hooks above, typed for the element you will attach it to. */
|
|
184
|
+
export function useElementRef<T extends HTMLElement>(): Ref<T> {
|
|
185
|
+
return useRef<T | null>(null);
|
|
186
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The hooks everything else is built out of.
|
|
4
|
+
//
|
|
5
|
+
// The one that matters most is `useStableCallback`. A callback recreated every
|
|
6
|
+
// render is the single most common cause of a React performance problem and of
|
|
7
|
+
// a subscription that tears itself down and sets itself up on every keystroke —
|
|
8
|
+
// and the usual fix, listing the callback in a dependency array, spreads the
|
|
9
|
+
// problem to every hook that takes it. A stable identity that always calls the
|
|
10
|
+
// latest closure fixes it once.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
useCallback,
|
|
14
|
+
useEffect,
|
|
15
|
+
useInsertionEffect,
|
|
16
|
+
useLayoutEffect,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
} from "@uniflowed/react";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `useLayoutEffect` in the browser, `useEffect` on the server.
|
|
23
|
+
*
|
|
24
|
+
* uf prerenders every static route, and React warns that `useLayoutEffect`
|
|
25
|
+
* does nothing during a server render — correctly, because there is no layout
|
|
26
|
+
* to read. Every hook here that measures or subscribes uses this, so a hook is
|
|
27
|
+
* not a reason a page cannot be prerendered.
|
|
28
|
+
*/
|
|
29
|
+
export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
|
|
30
|
+
typeof globalThis.document === "undefined" ? useEffect : useLayoutEffect;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A callback whose identity never changes and whose body is always the latest.
|
|
34
|
+
*
|
|
35
|
+
* This is the `useEvent` shape from React's own RFC. The ref is written in an
|
|
36
|
+
* insertion effect rather than in the render, because writing it during render
|
|
37
|
+
* makes the callback's behaviour depend on whether that render was thrown away
|
|
38
|
+
* — and it is written before any layout effect runs, so a subscription set up
|
|
39
|
+
* in one already sees the current body.
|
|
40
|
+
*/
|
|
41
|
+
export function useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
|
|
42
|
+
callback: (...args: TArgs) => TReturn,
|
|
43
|
+
): (...args: TArgs) => TReturn {
|
|
44
|
+
const latest = useRef(callback);
|
|
45
|
+
|
|
46
|
+
useInsertionEffect(() => {
|
|
47
|
+
latest.current = callback;
|
|
48
|
+
}, [callback]);
|
|
49
|
+
|
|
50
|
+
return useCallback((...args: TArgs) => latest.current(...args), []);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The value from the previous render, or `undefined` on the first. */
|
|
54
|
+
export function usePrevious<T>(value: T): T | void {
|
|
55
|
+
const previous = useRef<T | void>(undefined);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
previous.current = value;
|
|
58
|
+
}, [value]);
|
|
59
|
+
return previous.current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the component has mounted.
|
|
64
|
+
*
|
|
65
|
+
* For the case where a value differs between server and client and rendering
|
|
66
|
+
* the client's on the first pass would be a hydration mismatch: render the
|
|
67
|
+
* server's, then switch.
|
|
68
|
+
*/
|
|
69
|
+
export function useMounted(): boolean {
|
|
70
|
+
const [mounted, setMounted] = useState(false);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setMounted(true);
|
|
73
|
+
}, []);
|
|
74
|
+
return mounted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run `body` once, after mount. */
|
|
78
|
+
export function useMount(body: () => mixed): void {
|
|
79
|
+
const stable = useStableCallback(body);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
stable();
|
|
82
|
+
}, [stable]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Run `body` once, at unmount. */
|
|
86
|
+
export function useUnmount(body: () => mixed): void {
|
|
87
|
+
const stable = useStableCallback(body);
|
|
88
|
+
useEffect(() => () => void stable(), [stable]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Force a re-render.
|
|
93
|
+
*
|
|
94
|
+
* A counter rather than a boolean, because two renders in a row must both
|
|
95
|
+
* change the state or React drops the second.
|
|
96
|
+
*/
|
|
97
|
+
export function useRerender(): () => void {
|
|
98
|
+
const [, setTick] = useState(0);
|
|
99
|
+
return useCallback(() => setTick((tick) => tick + 1), []);
|
|
100
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// State with a shape.
|
|
4
|
+
//
|
|
5
|
+
// `useStorage` is the one worth reading. Persisted state has three problems a
|
|
6
|
+
// `useState` plus a `useEffect` does not solve: the first render on a
|
|
7
|
+
// prerendered page has no storage to read, two components using the same key
|
|
8
|
+
// must agree, and another tab writing the key should be seen. All three are
|
|
9
|
+
// what `useSyncExternalStore` is for.
|
|
10
|
+
|
|
11
|
+
import { useCallback, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
|
|
12
|
+
|
|
13
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
14
|
+
|
|
15
|
+
/** A boolean with the three things a caller ever does to one. */
|
|
16
|
+
export function useToggle(initial: boolean = false): {|
|
|
17
|
+
readonly on: boolean,
|
|
18
|
+
readonly toggle: () => void,
|
|
19
|
+
readonly set: (value: boolean) => void,
|
|
20
|
+
|} {
|
|
21
|
+
const [on, setOn] = useState(initial);
|
|
22
|
+
const toggle = useCallback(() => setOn((value) => !value), []);
|
|
23
|
+
return useMemo(() => ({ on, toggle, set: setOn }), [on, toggle]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A number, optionally clamped. */
|
|
27
|
+
export function useCounter(
|
|
28
|
+
initial: number = 0,
|
|
29
|
+
bounds?: {| readonly min?: number, readonly max?: number |},
|
|
30
|
+
): {|
|
|
31
|
+
readonly count: number,
|
|
32
|
+
readonly increment: (by?: number) => void,
|
|
33
|
+
readonly decrement: (by?: number) => void,
|
|
34
|
+
readonly set: (value: number) => void,
|
|
35
|
+
readonly reset: () => void,
|
|
36
|
+
|} {
|
|
37
|
+
const min = bounds?.min;
|
|
38
|
+
const max = bounds?.max;
|
|
39
|
+
|
|
40
|
+
const clamp = useCallback(
|
|
41
|
+
(value: number) => {
|
|
42
|
+
const lower = min == null ? value : Math.max(min, value);
|
|
43
|
+
return max == null ? lower : Math.min(max, lower);
|
|
44
|
+
},
|
|
45
|
+
[min, max],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const [count, setCount] = useState(() => clamp(initial));
|
|
49
|
+
const move = useCallback((delta: number) => setCount((value) => clamp(value + delta)), [clamp]);
|
|
50
|
+
|
|
51
|
+
return useMemo(
|
|
52
|
+
() => ({
|
|
53
|
+
count,
|
|
54
|
+
increment: (by?: number) => move(by ?? 1),
|
|
55
|
+
decrement: (by?: number) => move(-(by ?? 1)),
|
|
56
|
+
set: (value: number) => setCount(clamp(value)),
|
|
57
|
+
reset: () => setCount(clamp(initial)),
|
|
58
|
+
}),
|
|
59
|
+
[count, move, clamp, initial],
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every subscriber of a storage key, so a write is seen by all of them.
|
|
65
|
+
*
|
|
66
|
+
* A `storage` event does not fire in the tab that made the change, so without
|
|
67
|
+
* this two components sharing a key drift apart until one of them re-renders
|
|
68
|
+
* for an unrelated reason.
|
|
69
|
+
*/
|
|
70
|
+
const listeners: Map<string, Set<() => void>> = new Map();
|
|
71
|
+
|
|
72
|
+
function announce(key: string): void {
|
|
73
|
+
for (const listener of listeners.get(key) ?? []) {
|
|
74
|
+
listener();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function area(session: boolean): mixed {
|
|
79
|
+
try {
|
|
80
|
+
return session ? globalThis.sessionStorage : globalThis.localStorage;
|
|
81
|
+
} catch {
|
|
82
|
+
// A browser with site data blocked throws on the property itself.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* State kept in `localStorage`, or in `sessionStorage`.
|
|
89
|
+
*
|
|
90
|
+
* `initial` is what a prerender uses and what an unset or unreadable key falls
|
|
91
|
+
* back to, so the first paint is stated rather than accidental. A value that
|
|
92
|
+
* will not parse is treated as absent rather than thrown: storage is shared
|
|
93
|
+
* with older versions of the same application, and refusing to start because
|
|
94
|
+
* of a stale key would be worse than starting fresh.
|
|
95
|
+
*/
|
|
96
|
+
export function useStorage<T>(
|
|
97
|
+
key: string,
|
|
98
|
+
initial: T,
|
|
99
|
+
options?: {| readonly session?: boolean |},
|
|
100
|
+
): [T, (value: T) => void] {
|
|
101
|
+
const session = options?.session ?? false;
|
|
102
|
+
|
|
103
|
+
const subscribe = useCallback(
|
|
104
|
+
(notify: () => void) => {
|
|
105
|
+
const set = listeners.get(key) ?? new Set();
|
|
106
|
+
set.add(notify);
|
|
107
|
+
listeners.set(key, set);
|
|
108
|
+
const onStorage = (event: mixed) => {
|
|
109
|
+
if ((event as any)?.key === key) {
|
|
110
|
+
notify();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const win: any = globalThis.window ?? globalThis;
|
|
114
|
+
win.addEventListener?.("storage", onStorage);
|
|
115
|
+
return () => {
|
|
116
|
+
set.delete(notify);
|
|
117
|
+
win.removeEventListener?.("storage", onStorage);
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
[key],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const raw = useSyncExternalStore(
|
|
124
|
+
subscribe,
|
|
125
|
+
() => {
|
|
126
|
+
const store: any = area(session);
|
|
127
|
+
try {
|
|
128
|
+
return store?.getItem(key) ?? null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
() => null,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const value = useMemo(() => {
|
|
137
|
+
if (raw == null) {
|
|
138
|
+
return initial;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
return initial;
|
|
144
|
+
}
|
|
145
|
+
}, [raw, initial]);
|
|
146
|
+
|
|
147
|
+
const write = useStableCallback((next: T) => {
|
|
148
|
+
const store: any = area(session);
|
|
149
|
+
try {
|
|
150
|
+
store?.setItem(key, JSON.stringify(next));
|
|
151
|
+
} catch {
|
|
152
|
+
// Full, or blocked. The announcement still happens so the components
|
|
153
|
+
// sharing this key agree with each other for this session.
|
|
154
|
+
}
|
|
155
|
+
announce(key);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return [value, write];
|
|
159
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Timers that stop when the component does.
|
|
4
|
+
//
|
|
5
|
+
// Every one of these exists because the hand-written version leaks: a
|
|
6
|
+
// `setInterval` in a `useEffect` whose dependency array includes the callback
|
|
7
|
+
// is torn down and restarted on every render, and one without the callback in
|
|
8
|
+
// the array calls a stale closure forever. `useStableCallback` removes the
|
|
9
|
+
// choice — the timer is set once and always calls the current body.
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState } from "@uniflowed/react";
|
|
12
|
+
|
|
13
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Call `body` every `millis`, or not at all when `millis` is null.
|
|
17
|
+
*
|
|
18
|
+
* Null rather than a separate `enabled` flag because "no interval" and "an
|
|
19
|
+
* interval of nothing" are the same thing, and one argument cannot disagree
|
|
20
|
+
* with itself.
|
|
21
|
+
*/
|
|
22
|
+
export function useInterval(body: () => mixed, millis: number | null): void {
|
|
23
|
+
const stable = useStableCallback(body);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (millis == null) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const id = setInterval(stable, millis);
|
|
29
|
+
return () => clearInterval(id);
|
|
30
|
+
}, [stable, millis]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Call `body` once after `millis`, or not at all when `millis` is null. */
|
|
34
|
+
export function useTimeout(body: () => mixed, millis: number | null): void {
|
|
35
|
+
const stable = useStableCallback(body);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (millis == null) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const id = setTimeout(stable, millis);
|
|
41
|
+
return () => clearTimeout(id);
|
|
42
|
+
}, [stable, millis]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `value`, but only after it has stopped changing for `millis`.
|
|
47
|
+
*
|
|
48
|
+
* The classic use is a search box: the query updates on every keystroke and
|
|
49
|
+
* the request should not.
|
|
50
|
+
*/
|
|
51
|
+
export function useDebouncedValue<T>(value: T, millis: number): T {
|
|
52
|
+
const [settled, setSettled] = useState(value);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const id = setTimeout(() => setSettled(value), millis);
|
|
56
|
+
return () => clearTimeout(id);
|
|
57
|
+
}, [value, millis]);
|
|
58
|
+
|
|
59
|
+
return settled;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A callback that runs at most once per `millis`.
|
|
64
|
+
*
|
|
65
|
+
* Leading edge: the first call goes through immediately and later ones inside
|
|
66
|
+
* the window are dropped, which is what a scroll or resize handler wants —
|
|
67
|
+
* the trailing-edge version would make the first paint late.
|
|
68
|
+
*/
|
|
69
|
+
export function useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
70
|
+
body: (...args: TArgs) => mixed,
|
|
71
|
+
millis: number,
|
|
72
|
+
): (...args: TArgs) => void {
|
|
73
|
+
const stable = useStableCallback(body);
|
|
74
|
+
const last = useRef(0);
|
|
75
|
+
|
|
76
|
+
return useStableCallback((...args: TArgs) => {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (now - last.current >= millis) {
|
|
79
|
+
last.current = now;
|
|
80
|
+
stable(...args);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A callback that runs `millis` after the last time it was asked to.
|
|
87
|
+
*
|
|
88
|
+
* Trailing edge, and it cancels itself at unmount — the version people write
|
|
89
|
+
* calls `setState` on a component that is gone.
|
|
90
|
+
*/
|
|
91
|
+
export function useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
92
|
+
body: (...args: TArgs) => mixed,
|
|
93
|
+
millis: number,
|
|
94
|
+
): (...args: TArgs) => void {
|
|
95
|
+
const stable = useStableCallback(body);
|
|
96
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
97
|
+
|
|
98
|
+
useEffect(
|
|
99
|
+
() => () => {
|
|
100
|
+
if (timer.current != null) {
|
|
101
|
+
clearTimeout(timer.current);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return useStableCallback((...args: TArgs) => {
|
|
108
|
+
if (timer.current != null) {
|
|
109
|
+
clearTimeout(timer.current);
|
|
110
|
+
}
|
|
111
|
+
timer.current = setTimeout(() => {
|
|
112
|
+
timer.current = null;
|
|
113
|
+
stable(...args);
|
|
114
|
+
}, millis);
|
|
115
|
+
});
|
|
116
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/hooks",
|
|
3
|
+
"version": "0.0.0-alpha.2",
|
|
4
|
+
"description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/hooks"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"internal"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@uniflowed/react": "0.0.0-alpha.2"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": ">=19"
|
|
25
|
+
}
|
|
26
|
+
}
|