@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.6
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 +96 -16
- package/browser.js +576 -60
- package/channels.js +224 -0
- package/dom.js +262 -58
- package/index.js +138 -21
- package/keyboard.js +328 -0
- package/lifecycle.js +6 -6
- package/package.json +4 -2
- package/state.js +343 -29
- package/timing.js +296 -16
package/async.js
CHANGED
|
@@ -20,14 +20,29 @@
|
|
|
20
20
|
// component's life nor a timer nor a DOM node, and hiding it inside one of
|
|
21
21
|
// those would make all three harder to name.
|
|
22
22
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
25
27
|
// `@uniflowed/query`'s, and the boundary is what keeps `useAsync` small enough
|
|
26
28
|
// to read in one sitting. When a caller needs a cache they should change
|
|
27
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.
|
|
28
41
|
|
|
29
42
|
import { useCallback, useEffect, useState } from "@uniflowed/react";
|
|
30
43
|
|
|
44
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
45
|
+
|
|
31
46
|
/** What an in-flight, settled or failed call looks like. */
|
|
32
47
|
export type Async<T> = {|
|
|
33
48
|
readonly value: T | null,
|
|
@@ -37,14 +52,46 @@ export type Async<T> = {|
|
|
|
37
52
|
readonly reload: () => void,
|
|
38
53
|
|};
|
|
39
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
|
+
|
|
40
72
|
/**
|
|
41
73
|
* Call `body` when `deps` change, and report what happened.
|
|
42
74
|
*
|
|
43
75
|
* The previous value stays on screen while a reload is in flight, because
|
|
44
76
|
* blanking the page to show a spinner every time a filter changes is worse
|
|
45
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.
|
|
46
89
|
*/
|
|
47
|
-
export
|
|
90
|
+
export hook useAsync<T>(
|
|
91
|
+
body: (signal: AbortSignal) => Promise<T>,
|
|
92
|
+
deps: $ReadOnlyArray<mixed>,
|
|
93
|
+
options?: AsyncOptions,
|
|
94
|
+
): Async<T> {
|
|
48
95
|
const [state, setState] = useState<{|
|
|
49
96
|
value: T | null,
|
|
50
97
|
error: Error | null,
|
|
@@ -56,35 +103,68 @@ export function useAsync<T>(body: () => Promise<T>, deps: $ReadOnlyArray<mixed>)
|
|
|
56
103
|
const [attempt, setAttempt] = useState(0);
|
|
57
104
|
const reload = useCallback(() => setAttempt((current) => current + 1), []);
|
|
58
105
|
|
|
106
|
+
const retries = options?.retry ?? 0;
|
|
107
|
+
const call = useStableCallback(body);
|
|
108
|
+
const wait = useStableCallback<[number], number>(options?.retryDelay ?? backoff);
|
|
109
|
+
|
|
59
110
|
useEffect(() => {
|
|
60
111
|
// Set when this effect is superseded — by a dependency change, a reload,
|
|
61
112
|
// or an unmount. React runs the cleanup before the next run, so the
|
|
62
113
|
// request that is no longer wanted knows not to write.
|
|
63
114
|
let ignore = false;
|
|
64
|
-
|
|
115
|
+
let sleeping: TimeoutID | null = null;
|
|
116
|
+
const controller = new AbortController();
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
(
|
|
73
|
-
|
|
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
|
+
}
|
|
74
148
|
setState({
|
|
75
149
|
value: null,
|
|
76
150
|
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
|
|
77
151
|
pending: false,
|
|
78
152
|
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
run(0);
|
|
82
158
|
|
|
83
159
|
return () => {
|
|
84
160
|
ignore = true;
|
|
161
|
+
if (sleeping != null) {
|
|
162
|
+
clearTimeout(sleeping);
|
|
163
|
+
}
|
|
164
|
+
controller.abort();
|
|
85
165
|
};
|
|
86
166
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
-
}, [...deps, attempt]);
|
|
167
|
+
}, [...deps, attempt, retries, call, wait]);
|
|
88
168
|
|
|
89
169
|
return { ...state, reload };
|
|
90
170
|
}
|