@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 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
- // Deliberately not here, and not in this package at all: caching, retries,
24
- // deduplication between components, invalidation. Those are
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 function useAsync<T>(body: () => Promise<T>, deps: $ReadOnlyArray<mixed>): Async<T> {
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
- setState((current) => ({ ...current, pending: true }));
115
+ let sleeping: TimeoutID | null = null;
116
+ const controller = new AbortController();
65
117
 
66
- body().then(
67
- (value) => {
68
- if (!ignore) {
69
- setState({ value, error: null, pending: false });
70
- }
71
- },
72
- (thrown) => {
73
- if (!ignore) {
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
  }