@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.41

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