@uniflowed/hooks 0.0.0-alpha.2 → 0.0.0-alpha.5
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 +159 -13
- package/keyboard.js +328 -0
- package/{internal/lifecycle.js → lifecycle.js} +21 -7
- package/package.json +12 -5
- package/state.js +487 -0
- package/timing.js +408 -0
- package/internal/async.js +0 -77
- package/internal/browser.js +0 -145
- package/internal/element.js +0 -186
- package/internal/state.js +0 -159
- package/internal/timing.js +0 -116
package/timing.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/timing`: 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
|
+
// # What belongs in this module
|
|
12
|
+
//
|
|
13
|
+
// A hook whose subject is *when* something runs: on a schedule, after a wait,
|
|
14
|
+
// no more often than some rate, on the next frame, once the reader has stopped
|
|
15
|
+
// touching anything. Every one of them owns a handle that has to be cleared,
|
|
16
|
+
// and the cleanup is the reason the hook exists rather than a detail of it.
|
|
17
|
+
//
|
|
18
|
+
// `useNow` and `useTimeAgo` belong here for the same reason, which is easy to
|
|
19
|
+
// miss: what is difficult about "3 minutes ago" is not the words, it is
|
|
20
|
+
// deciding how often the words have to be worked out again. A label a minute
|
|
21
|
+
// old must be redrawn every second and one a week old must not be redrawn at
|
|
22
|
+
// all, and getting that wrong is either a wrong label or a component that
|
|
23
|
+
// re-renders sixty times a second forever.
|
|
24
|
+
//
|
|
25
|
+
// Not here: `useMount` and `useUnmount`, which are about the component's life
|
|
26
|
+
// rather than a clock, and live in `lifecycle.js`; and rendering a time, which
|
|
27
|
+
// is `@uniflowed/web`'s `Time`. The split between that component and
|
|
28
|
+
// `useTimeAgo` is the split between markup and schedule — `Time` decides what
|
|
29
|
+
// a `<time>` element contains and how it survives hydration, and works out its
|
|
30
|
+
// relative text exactly once; `useTimeAgo` is for a label that has to stay
|
|
31
|
+
// true while the reader looks at it. This package does not depend on
|
|
32
|
+
// `@uniflowed/web` to get there, because a hook library that pulled in a
|
|
33
|
+
// component library would be the wrong direction for the one arrow between
|
|
34
|
+
// them.
|
|
35
|
+
|
|
36
|
+
import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
37
|
+
|
|
38
|
+
import { browserWindow } from "./browser.js";
|
|
39
|
+
import { useMounted, useStableCallback } from "./lifecycle.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Call `body` every `millis`, or not at all when `millis` is null.
|
|
43
|
+
*
|
|
44
|
+
* Null rather than a separate `enabled` flag because "no interval" and "an
|
|
45
|
+
* interval of nothing" are the same thing, and one argument cannot disagree
|
|
46
|
+
* with itself.
|
|
47
|
+
*/
|
|
48
|
+
export hook useInterval(body: () => mixed, millis: number | null): void {
|
|
49
|
+
const stable = useStableCallback(body);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (millis == null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const id = setInterval(stable, millis);
|
|
55
|
+
return () => clearInterval(id);
|
|
56
|
+
}, [stable, millis]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Call `body` once after `millis`, or not at all when `millis` is null. */
|
|
60
|
+
export hook useTimeout(body: () => mixed, millis: number | null): void {
|
|
61
|
+
const stable = useStableCallback(body);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (millis == null) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const id = setTimeout(stable, millis);
|
|
67
|
+
return () => clearTimeout(id);
|
|
68
|
+
}, [stable, millis]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `value`, but only after it has stopped changing for `millis`.
|
|
73
|
+
*
|
|
74
|
+
* The classic use is a search box: the query updates on every keystroke and
|
|
75
|
+
* the request should not.
|
|
76
|
+
*/
|
|
77
|
+
export hook useDebouncedValue<T>(value: T, millis: number): T {
|
|
78
|
+
const [settled, setSettled] = useState(value);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const id = setTimeout(() => setSettled(value), millis);
|
|
82
|
+
return () => clearTimeout(id);
|
|
83
|
+
}, [value, millis]);
|
|
84
|
+
|
|
85
|
+
return settled;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A callback that runs at most once per `millis`.
|
|
90
|
+
*
|
|
91
|
+
* Leading edge: the first call goes through immediately and later ones inside
|
|
92
|
+
* the window are dropped, which is what a scroll or resize handler wants —
|
|
93
|
+
* the trailing-edge version would make the first paint late.
|
|
94
|
+
*/
|
|
95
|
+
export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
96
|
+
body: (...args: TArgs) => mixed,
|
|
97
|
+
millis: number,
|
|
98
|
+
): (...args: TArgs) => void {
|
|
99
|
+
// Written out rather than inferred: Flow cannot instantiate one function's
|
|
100
|
+
// rest-parameter type variable from another's, so the type arguments are
|
|
101
|
+
// given here and at every other call in this file.
|
|
102
|
+
const stable = useStableCallback<TArgs, mixed>(body);
|
|
103
|
+
const last = useRef(0);
|
|
104
|
+
|
|
105
|
+
return useStableCallback<TArgs, void>((...args: TArgs) => {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (now - last.current >= millis) {
|
|
108
|
+
last.current = now;
|
|
109
|
+
stable(...args);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A callback that runs `millis` after the last time it was asked to.
|
|
116
|
+
*
|
|
117
|
+
* Trailing edge, and it cancels itself at unmount — the version people write
|
|
118
|
+
* calls `setState` on a component that is gone.
|
|
119
|
+
*/
|
|
120
|
+
export hook useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
121
|
+
body: (...args: TArgs) => mixed,
|
|
122
|
+
millis: number,
|
|
123
|
+
): (...args: TArgs) => void {
|
|
124
|
+
const stable = useStableCallback<TArgs, mixed>(body);
|
|
125
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
126
|
+
|
|
127
|
+
useEffect(
|
|
128
|
+
() => () => {
|
|
129
|
+
if (timer.current != null) {
|
|
130
|
+
clearTimeout(timer.current);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return useStableCallback<TArgs, void>((...args: TArgs) => {
|
|
137
|
+
if (timer.current != null) {
|
|
138
|
+
clearTimeout(timer.current);
|
|
139
|
+
}
|
|
140
|
+
timer.current = setTimeout(() => {
|
|
141
|
+
timer.current = null;
|
|
142
|
+
stable(...args);
|
|
143
|
+
}, millis);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run `body` before every frame the browser paints, while `active`.
|
|
149
|
+
*
|
|
150
|
+
* `delta` is the milliseconds since the previous frame and is zero on the
|
|
151
|
+
* first, which is what an animation integrates against: a frame that took 32ms
|
|
152
|
+
* because the tab was busy has to move twice as far as one that took 16ms, and
|
|
153
|
+
* a hand-written loop that assumes sixty a second runs at half speed on a
|
|
154
|
+
* hundred-and-twenty-hertz display.
|
|
155
|
+
*
|
|
156
|
+
* Nothing runs before hydration: there is no frame to paint during a prerender,
|
|
157
|
+
* and the effect that would ask for one does not run there.
|
|
158
|
+
*/
|
|
159
|
+
export hook useAnimationFrame(
|
|
160
|
+
body: (frame: {| readonly delta: number, readonly time: number |}) => mixed,
|
|
161
|
+
active: boolean = true,
|
|
162
|
+
): void {
|
|
163
|
+
const stable = useStableCallback(body);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const win = browserWindow();
|
|
167
|
+
if (!active || win == null || win.requestAnimationFrame == null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let handle: AnimationFrameID | null = null;
|
|
171
|
+
let previous: number | null = null;
|
|
172
|
+
|
|
173
|
+
const step = (time: number) => {
|
|
174
|
+
const delta = previous == null ? 0 : time - previous;
|
|
175
|
+
previous = time;
|
|
176
|
+
// Asked for before the body runs, so a body that throws does not stop
|
|
177
|
+
// the loop silently — it stops it loudly, on the next frame, having
|
|
178
|
+
// already reported the throw to the browser.
|
|
179
|
+
handle = win.requestAnimationFrame?.(step) ?? null;
|
|
180
|
+
stable({ delta, time });
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
handle = win.requestAnimationFrame(step);
|
|
184
|
+
return () => {
|
|
185
|
+
if (handle != null) {
|
|
186
|
+
win.cancelAnimationFrame?.(handle);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}, [active, stable]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** What counts as the reader still being there. */
|
|
193
|
+
const ACTIVITY: $ReadOnlyArray<string> = [
|
|
194
|
+
"pointermove",
|
|
195
|
+
"pointerdown",
|
|
196
|
+
"keydown",
|
|
197
|
+
"wheel",
|
|
198
|
+
"touchstart",
|
|
199
|
+
"scroll",
|
|
200
|
+
"visibilitychange",
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the reader has stopped doing anything for `millis`.
|
|
205
|
+
*
|
|
206
|
+
* `false` on a server and on the first client render, which is the answer that
|
|
207
|
+
* cannot be wrong: nobody is idle before the page exists, and starting at
|
|
208
|
+
* `true` would flash whatever the page shows an idle reader.
|
|
209
|
+
*
|
|
210
|
+
* The listeners are passive and on the window rather than on any element, so
|
|
211
|
+
* this costs nothing on a touch screen and sees activity anywhere on the page.
|
|
212
|
+
*/
|
|
213
|
+
export hook useIdle(
|
|
214
|
+
millis: number = 60_000,
|
|
215
|
+
options?: {| readonly events?: $ReadOnlyArray<string> |},
|
|
216
|
+
): boolean {
|
|
217
|
+
const [idle, setIdle] = useState(false);
|
|
218
|
+
const events = options?.events ?? ACTIVITY;
|
|
219
|
+
// Compared by contents: an array written inline in the call is a new array
|
|
220
|
+
// every render, and depending on its identity would re-listen every render.
|
|
221
|
+
const key = events.join(",");
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const win = browserWindow();
|
|
225
|
+
if (win == null) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const names = key.split(",");
|
|
229
|
+
let timer: TimeoutID | null = null;
|
|
230
|
+
|
|
231
|
+
const wake = () => {
|
|
232
|
+
setIdle(false);
|
|
233
|
+
if (timer != null) {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
timer = setTimeout(() => setIdle(true), millis);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
wake();
|
|
240
|
+
for (const name of names) {
|
|
241
|
+
win.addEventListener(name, wake, { passive: true });
|
|
242
|
+
}
|
|
243
|
+
return () => {
|
|
244
|
+
if (timer != null) {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
for (const name of names) {
|
|
248
|
+
win.removeEventListener(name, wake);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}, [millis, key]);
|
|
252
|
+
|
|
253
|
+
return idle;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The current time, re-read every `millis`.
|
|
258
|
+
*
|
|
259
|
+
* The clock is read in the initial state rather than in an effect, so a
|
|
260
|
+
* client-only page has the right time on its first paint instead of a frame of
|
|
261
|
+
* something else. That read is the one impure thing in this package, and it is
|
|
262
|
+
* bounded: it happens once, the value is never re-read during a render, and a
|
|
263
|
+
* render React throws away is replaced by another whose clock is just as valid.
|
|
264
|
+
*
|
|
265
|
+
* A prerendered page that puts this on screen needs `serverValue`, because the
|
|
266
|
+
* server's clock and the reader's are not the same number and React compares
|
|
267
|
+
* the text. Given one, the first render on both sides is that value and the
|
|
268
|
+
* real time arrives with the first effect. `useTimeAgo` has already made this
|
|
269
|
+
* choice; prefer it for a label.
|
|
270
|
+
*/
|
|
271
|
+
export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date {
|
|
272
|
+
// The instant rather than the object: a caller writing `new Date(...)` in
|
|
273
|
+
// the call passes a different object every render, and a dependency on it
|
|
274
|
+
// would re-run the effect forever.
|
|
275
|
+
const since = serverValue == null ? null : serverValue.getTime();
|
|
276
|
+
const [now, setNow] = useState<Date>(() => (since == null ? new Date() : new Date(since)));
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (since != null) {
|
|
280
|
+
setNow(new Date());
|
|
281
|
+
}
|
|
282
|
+
}, [since]);
|
|
283
|
+
|
|
284
|
+
useInterval(() => setNow(new Date()), millis);
|
|
285
|
+
return now;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `Intl.RelativeTimeFormat`, which Flow's own library definition does not have.
|
|
290
|
+
*
|
|
291
|
+
* The vendored `intl.js` declares `Collator`, `DateTimeFormat`, `Locale`,
|
|
292
|
+
* `NumberFormat`, `PluralRules` and `Segmenter` and stops there, so
|
|
293
|
+
* `Intl.RelativeTimeFormat` is a missing property and `Intl$RelativeTimeFormatUnit`
|
|
294
|
+
* is an unresolvable name. Declaring the shape here is how this file names a
|
|
295
|
+
* global its checker has not caught up with — narrow, exactly as wide as what
|
|
296
|
+
* is called, and optional so that a runtime without the constructor is a
|
|
297
|
+
* branch rather than a crash.
|
|
298
|
+
*/
|
|
299
|
+
declare class RelativeTimeFormat {
|
|
300
|
+
constructor(locale?: string, options?: { numeric?: "always" | "auto", ... }): void;
|
|
301
|
+
format(value: number, unit: RelativeUnit): string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
declare var Intl: {
|
|
305
|
+
RelativeTimeFormat?: Class<RelativeTimeFormat>,
|
|
306
|
+
...
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** The units `ago` is willing to describe a gap in. */
|
|
310
|
+
type RelativeUnit = "year" | "month" | "day" | "hour" | "minute" | "second";
|
|
311
|
+
|
|
312
|
+
/** Milliseconds in each unit, largest first. */
|
|
313
|
+
const UNITS: $ReadOnlyArray<[RelativeUnit, number]> = [
|
|
314
|
+
["year", 31_536_000_000],
|
|
315
|
+
["month", 2_592_000_000],
|
|
316
|
+
["day", 86_400_000],
|
|
317
|
+
["hour", 3_600_000],
|
|
318
|
+
["minute", 60_000],
|
|
319
|
+
["second", 1_000],
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
/** How often a label this far from now has to be worked out again. */
|
|
323
|
+
function cadence(distance: number): number {
|
|
324
|
+
if (distance < 60_000) {
|
|
325
|
+
return 1_000;
|
|
326
|
+
}
|
|
327
|
+
if (distance < 3_600_000) {
|
|
328
|
+
return 30_000;
|
|
329
|
+
}
|
|
330
|
+
return 60_000;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* "3 minutes ago", or "in 3 minutes".
|
|
335
|
+
*
|
|
336
|
+
* Falls back to the instant itself where the browser has no
|
|
337
|
+
* `Intl.RelativeTimeFormat`, because a wrong-language string invented here
|
|
338
|
+
* would be worse than the unambiguous one.
|
|
339
|
+
*/
|
|
340
|
+
function ago(at: Date, from: Date, locale: string | void): string {
|
|
341
|
+
const Formatter = Intl.RelativeTimeFormat;
|
|
342
|
+
if (Formatter == null) {
|
|
343
|
+
return at.toISOString();
|
|
344
|
+
}
|
|
345
|
+
const difference = at.getTime() - from.getTime();
|
|
346
|
+
const formatter = new Formatter(locale, { numeric: "auto" });
|
|
347
|
+
for (const [unit, span] of UNITS) {
|
|
348
|
+
if (Math.abs(difference) >= span) {
|
|
349
|
+
return formatter.format(Math.round(difference / span), unit);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Under a second in either direction is "now", not "in 0 seconds".
|
|
353
|
+
return formatter.format(0, "second");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* "3 minutes ago", kept true while the reader looks at it.
|
|
358
|
+
*
|
|
359
|
+
* Before hydration and on the first client render this is `serverValue`,
|
|
360
|
+
* defaulting to the instant's UTC ISO string — the same choice
|
|
361
|
+
* `@uniflowed/web`'s `Time` makes, and for the same reason: the relative form
|
|
362
|
+
* depends on a clock and a locale that the server does not have, so rendering
|
|
363
|
+
* it on both sides would be a hydration mismatch by construction. The text is
|
|
364
|
+
* in the markup for a crawler and for a reader with no JavaScript, and becomes
|
|
365
|
+
* relative once the page is alive.
|
|
366
|
+
*
|
|
367
|
+
* The update rate follows the distance rather than being fixed: a label from
|
|
368
|
+
* this minute is redrawn every second, one from this hour every thirty, and an
|
|
369
|
+
* older one every minute. That is why this is not "call `useNow` and format
|
|
370
|
+
* it" — a fixed one-second clock re-renders a week-old timestamp 604,800 times
|
|
371
|
+
* to no effect.
|
|
372
|
+
*/
|
|
373
|
+
export hook useTimeAgo(
|
|
374
|
+
value: Date | string | number,
|
|
375
|
+
options?: {|
|
|
376
|
+
readonly serverValue?: string,
|
|
377
|
+
/** Override the schedule. `null` works it out once and leaves it. */
|
|
378
|
+
readonly interval?: number | null,
|
|
379
|
+
readonly locale?: string,
|
|
380
|
+
|},
|
|
381
|
+
): string {
|
|
382
|
+
const serverValue = options?.serverValue;
|
|
383
|
+
const override = options?.interval;
|
|
384
|
+
const locale = options?.locale;
|
|
385
|
+
|
|
386
|
+
const instant = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
387
|
+
const at = useMemo(() => new Date(instant), [instant]);
|
|
388
|
+
|
|
389
|
+
const mounted = useMounted();
|
|
390
|
+
// The schedule itself is the state, not the gap it was chosen from. Holding
|
|
391
|
+
// the gap would mean a render every time the clock moved *and* a second one
|
|
392
|
+
// to record the new gap; holding the schedule means `setSchedule` is handed
|
|
393
|
+
// the same number on all but the few ticks that cross a threshold, and React
|
|
394
|
+
// bails out of those renders entirely.
|
|
395
|
+
const [schedule, setSchedule] = useState(1_000);
|
|
396
|
+
const tick = override === undefined ? schedule : override;
|
|
397
|
+
const now = useNow(mounted ? tick : null);
|
|
398
|
+
|
|
399
|
+
const wanted = cadence(Math.abs(now.getTime() - instant));
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
setSchedule(wanted);
|
|
402
|
+
}, [wanted]);
|
|
403
|
+
|
|
404
|
+
if (!mounted) {
|
|
405
|
+
return serverValue ?? at.toISOString();
|
|
406
|
+
}
|
|
407
|
+
return ago(at, now, locale);
|
|
408
|
+
}
|
package/internal/async.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
package/internal/browser.js
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
}
|