@uniflowed/hooks 0.0.0-alpha.10
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/async.js +170 -0
- package/browser.js +675 -0
- package/channels.js +224 -0
- package/dom.js +407 -0
- package/index.js +206 -0
- package/keyboard.js +328 -0
- package/lifecycle.js +114 -0
- package/package.json +33 -0
- package/state.js +520 -0
- package/timing.js +408 -0
package/async.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/async`: 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
|
+
// # What belongs in this module
|
|
17
|
+
//
|
|
18
|
+
// A hook that starts one call and holds its pending, resolved and failed
|
|
19
|
+
// states. One file for one hook, because the subject is neither the
|
|
20
|
+
// component's life nor a timer nor a DOM node, and hiding it inside one of
|
|
21
|
+
// those would make all three harder to name.
|
|
22
|
+
//
|
|
23
|
+
// # Where the line with `@uniflowed/query` is, exactly
|
|
24
|
+
//
|
|
25
|
+
// Not here, and not in this package at all: caching, deduplication between
|
|
26
|
+
// components, invalidation, stale-while-revalidate. Those are
|
|
27
|
+
// `@uniflowed/query`'s, and the boundary is what keeps `useAsync` small enough
|
|
28
|
+
// to read in one sitting. When a caller needs a cache they should change
|
|
29
|
+
// packages, not discover that this hook grew one.
|
|
30
|
+
//
|
|
31
|
+
// Cancellation and retry are on this side of that line, and the reason is that
|
|
32
|
+
// neither of them needs a cache to mean anything. A component that unmounts
|
|
33
|
+
// while a request is in flight should stop the request, not merely ignore it —
|
|
34
|
+
// the connection is the cost, and `AbortSignal` is how the platform says so.
|
|
35
|
+
// A call that failed once on a flaky connection should be able to try again
|
|
36
|
+
// without the caller writing a loop that has to know about the cleanup flag.
|
|
37
|
+
// What query owns is the *shared* version of both: one backoff across every
|
|
38
|
+
// component watching a key, cancellation that has to decide whether another
|
|
39
|
+
// observer still wants the answer. Nothing here is shared, so nothing here has
|
|
40
|
+
// to decide that.
|
|
41
|
+
|
|
42
|
+
import { useCallback, useEffect, useState } from "@uniflowed/react";
|
|
43
|
+
|
|
44
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
45
|
+
|
|
46
|
+
/** What an in-flight, settled or failed call looks like. */
|
|
47
|
+
export type Async<T> = {|
|
|
48
|
+
readonly value: T | null,
|
|
49
|
+
readonly error: Error | null,
|
|
50
|
+
readonly pending: boolean,
|
|
51
|
+
/** Run it again, keeping whatever is on screen until the new value lands. */
|
|
52
|
+
readonly reload: () => void,
|
|
53
|
+
|};
|
|
54
|
+
|
|
55
|
+
/** How hard to try. */
|
|
56
|
+
export type AsyncOptions = {|
|
|
57
|
+
/** How many times to try *again* after a failure. Zero, by default. */
|
|
58
|
+
readonly retry?: number,
|
|
59
|
+
/**
|
|
60
|
+
* How long to wait before attempt `attempt` (counting from zero).
|
|
61
|
+
*
|
|
62
|
+
* Exponential with a ceiling, by default. A fixed delay is `() => 200`.
|
|
63
|
+
*/
|
|
64
|
+
readonly retryDelay?: (attempt: number) => number,
|
|
65
|
+
|};
|
|
66
|
+
|
|
67
|
+
/** Exponential backoff, capped so a long-lived page does not wait for minutes. */
|
|
68
|
+
function backoff(attempt: number): number {
|
|
69
|
+
return Math.min(200 * 2 ** attempt, 5_000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Call `body` when `deps` change, and report what happened.
|
|
74
|
+
*
|
|
75
|
+
* The previous value stays on screen while a reload is in flight, because
|
|
76
|
+
* blanking the page to show a spinner every time a filter changes is worse
|
|
77
|
+
* than showing slightly stale data for a moment. `pending` says which it is.
|
|
78
|
+
*
|
|
79
|
+
* `body` is handed an `AbortSignal` that is aborted when the call is
|
|
80
|
+
* superseded — by a dependency change, a `reload`, or an unmount. Passing it
|
|
81
|
+
* to `fetch` is what makes a cancelled request actually stop; a `body` that
|
|
82
|
+
* ignores it is still correct, because the effect's own flag is what decides
|
|
83
|
+
* whether a result is written.
|
|
84
|
+
*
|
|
85
|
+
* Nothing here runs during a prerender: `useEffect` does not run on a server,
|
|
86
|
+
* so a server-rendered page shows `pending` with no value, which is the same
|
|
87
|
+
* thing the client's first render shows. Data a page needs *in* its HTML
|
|
88
|
+
* belongs in a route loader, not in this hook.
|
|
89
|
+
*/
|
|
90
|
+
export hook useAsync<T>(
|
|
91
|
+
body: (signal: AbortSignal) => Promise<T>,
|
|
92
|
+
deps: $ReadOnlyArray<mixed>,
|
|
93
|
+
options?: AsyncOptions,
|
|
94
|
+
): Async<T> {
|
|
95
|
+
const [state, setState] = useState<{|
|
|
96
|
+
value: T | null,
|
|
97
|
+
error: Error | null,
|
|
98
|
+
pending: boolean,
|
|
99
|
+
|}>({ value: null, error: null, pending: true });
|
|
100
|
+
|
|
101
|
+
// Changing this is what re-runs the effect, so `reload` is a state change
|
|
102
|
+
// rather than a function the effect has to be told about.
|
|
103
|
+
const [attempt, setAttempt] = useState(0);
|
|
104
|
+
const reload = useCallback(() => setAttempt((current) => current + 1), []);
|
|
105
|
+
|
|
106
|
+
const retries = options?.retry ?? 0;
|
|
107
|
+
const call = useStableCallback(body);
|
|
108
|
+
const wait = useStableCallback<[number], number>(options?.retryDelay ?? backoff);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
// Set when this effect is superseded — by a dependency change, a reload,
|
|
112
|
+
// or an unmount. React runs the cleanup before the next run, so the
|
|
113
|
+
// request that is no longer wanted knows not to write.
|
|
114
|
+
let ignore = false;
|
|
115
|
+
let sleeping: TimeoutID | null = null;
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
|
|
118
|
+
// Guarded rather than unconditional: a reload that arrives while a call is
|
|
119
|
+
// already in flight would otherwise build a new state object, and a new
|
|
120
|
+
// object is a re-render that changes nothing anyone can see.
|
|
121
|
+
setState((current) => (current.pending ? current : { ...current, pending: true }));
|
|
122
|
+
|
|
123
|
+
const run = (tries: number) => {
|
|
124
|
+
call(controller.signal).then(
|
|
125
|
+
(value) => {
|
|
126
|
+
if (!ignore) {
|
|
127
|
+
setState({ value, error: null, pending: false });
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
(thrown) => {
|
|
131
|
+
if (ignore) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// An abort is not a failure to report: the only reason this call was
|
|
135
|
+
// aborted is that nobody wants its answer any more.
|
|
136
|
+
if (controller.signal.aborted) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (tries < retries) {
|
|
140
|
+
sleeping = setTimeout(() => {
|
|
141
|
+
sleeping = null;
|
|
142
|
+
if (!ignore) {
|
|
143
|
+
run(tries + 1);
|
|
144
|
+
}
|
|
145
|
+
}, wait(tries));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setState({
|
|
149
|
+
value: null,
|
|
150
|
+
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
|
|
151
|
+
pending: false,
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
run(0);
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
ignore = true;
|
|
161
|
+
if (sleeping != null) {
|
|
162
|
+
clearTimeout(sleeping);
|
|
163
|
+
}
|
|
164
|
+
controller.abort();
|
|
165
|
+
};
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [...deps, attempt, retries, call, wait]);
|
|
168
|
+
|
|
169
|
+
return { ...state, reload };
|
|
170
|
+
}
|