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