@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 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
+ }