@uniflowed/hooks 0.0.0-alpha.11 → 0.0.0-alpha.13
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/browser.js +322 -35
- package/events.js +298 -0
- package/index.js +42 -4
- package/package.json +5 -2
- package/render.js +274 -0
- package/timing.js +39 -10
package/browser.js
CHANGED
|
@@ -13,17 +13,52 @@
|
|
|
13
13
|
// # What belongs in this module
|
|
14
14
|
//
|
|
15
15
|
// A reading of the one browser the page is in: its size, its scroll offset,
|
|
16
|
-
// its connection, its visibility, its position on the earth, the
|
|
17
|
-
//
|
|
18
|
-
// answer at a time, nobody has to pass anything
|
|
19
|
-
// answer at all — which is why every hook here
|
|
20
|
-
// from the caller or states an honest default.
|
|
16
|
+
// its connection, its visibility, its position on the earth, the fragment in
|
|
17
|
+
// its address bar, the preferences the reader set, the permissions the reader
|
|
18
|
+
// granted. There is exactly one answer at a time, nobody has to pass anything
|
|
19
|
+
// in to ask, and a server has no answer at all — which is why every hook here
|
|
20
|
+
// either takes a server value from the caller or states an honest default.
|
|
21
21
|
//
|
|
22
22
|
// Not here: anything about a specific element, which needs a ref and lives in
|
|
23
23
|
// `dom.js`. `useDocumentVisible` is the closest call in the package and stays
|
|
24
24
|
// here, because the document is the environment rather than a node a caller
|
|
25
25
|
// chose.
|
|
26
26
|
//
|
|
27
|
+
// The path and the query are not here either, and that one is a boundary rather
|
|
28
|
+
// than a filing decision: they are on the request, so `@uniflowed/router` has
|
|
29
|
+
// already resolved them and `useRoute` is where a page reads them. What is here
|
|
30
|
+
// is the fragment, which the browser strips before the request goes out and
|
|
31
|
+
// which therefore nothing on the server has ever seen. See `useHash`.
|
|
32
|
+
//
|
|
33
|
+
// # What the first render produces
|
|
34
|
+
//
|
|
35
|
+
// Every hook below answers twice with the same value — once during the
|
|
36
|
+
// prerender, once during the client's hydrating render — and only then with
|
|
37
|
+
// what the browser actually says. That second column is not a detail: it is the
|
|
38
|
+
// value React compares the two trees at, so a hook whose first client render
|
|
39
|
+
// disagreed with the server's would report a mismatch on a page that did
|
|
40
|
+
// nothing wrong.
|
|
41
|
+
//
|
|
42
|
+
// | Hook | Prerender, and the first client render | Afterwards |
|
|
43
|
+
// | --- | --- | --- |
|
|
44
|
+
// | `useSupported` | `false` | what `probe` says |
|
|
45
|
+
// | `useMediaQuery` | `serverValue`, default `false` | whether it matches |
|
|
46
|
+
// | `usePreferredColorScheme` | `serverValue`, default `"light"` | the reader's setting |
|
|
47
|
+
// | `usePrefersReducedMotion` | `serverValue`, default `false` | the reader's setting |
|
|
48
|
+
// | `useOnline` | `serverValue`, default `true` | `navigator.onLine` |
|
|
49
|
+
// | `useDocumentVisible` | `serverValue`, default `true` | the visibility state |
|
|
50
|
+
// | `useHash` | `""`, and no way to say otherwise | the fragment |
|
|
51
|
+
// | `useWindowSize` | `serverValue`, default `0x0` | the viewport |
|
|
52
|
+
// | `useWindowScroll` | `serverValue`, default `0,0` | the offset |
|
|
53
|
+
// | `useNetwork` | `{ online: serverValue, measured: null }` | what Chromium measured |
|
|
54
|
+
// | `useGeolocation` | `"unsupported"` | `"pending"`, then a fix or a failure |
|
|
55
|
+
// | `usePermission` | `"unknown"` | what the browser answers |
|
|
56
|
+
// | `useScrollLock` | nothing at all — effects do not run in a prerender | the page is held |
|
|
57
|
+
//
|
|
58
|
+
// `tests/library/hooks-ssr.test.js` renders every one of them in a process with
|
|
59
|
+
// no document and asserts the markup, so a row of this table that stopped being
|
|
60
|
+
// true would fail there rather than in somebody's browser.
|
|
61
|
+
//
|
|
27
62
|
// # The one that writes
|
|
28
63
|
//
|
|
29
64
|
// `useScrollLock` is the exception to "reading", and it is here rather than in
|
|
@@ -41,6 +76,12 @@
|
|
|
41
76
|
// subscriptions whose *first* value only arrives asynchronously, so there is
|
|
42
77
|
// nothing for a snapshot to return until it does, and `useScrollLock` writes.
|
|
43
78
|
// Each says so where it is defined.
|
|
79
|
+
//
|
|
80
|
+
// `useHash` is a store whose event the browser only half provides —
|
|
81
|
+
// `hashchange` and `popstate` cover what a reader does, and a `pushState` fires
|
|
82
|
+
// neither — so it keeps a registry of its own subscribers and announces its own
|
|
83
|
+
// writes, the way `useStorage` does. That is a store with a gap named in it,
|
|
84
|
+
// not a fourth kind of hook.
|
|
44
85
|
|
|
45
86
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
|
|
46
87
|
|
|
@@ -76,6 +117,35 @@ export type BrowserNavigator = {
|
|
|
76
117
|
...
|
|
77
118
|
};
|
|
78
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The part of `location` this module reads.
|
|
122
|
+
*
|
|
123
|
+
* `href` and the fragment, and nothing else. The path and the query are on the
|
|
124
|
+
* request, so `@uniflowed/router` has already resolved them and a second
|
|
125
|
+
* reading of them here would be a second answer to a settled question — see
|
|
126
|
+
* `useHash`.
|
|
127
|
+
*/
|
|
128
|
+
export type BrowserLocation = {
|
|
129
|
+
readonly href: string,
|
|
130
|
+
readonly hash: string,
|
|
131
|
+
...
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The part of `history` this module writes through.
|
|
136
|
+
*
|
|
137
|
+
* `state` is read so that a fragment written from here hands back whatever a
|
|
138
|
+
* router put there, rather than clearing it. The second parameter of both
|
|
139
|
+
* methods has been ignored by every browser since the API shipped, and is
|
|
140
|
+
* typed rather than omitted because it is positional.
|
|
141
|
+
*/
|
|
142
|
+
export type BrowserHistory = {
|
|
143
|
+
readonly state: mixed,
|
|
144
|
+
readonly pushState: (state: mixed, unused: string, url: string) => void,
|
|
145
|
+
readonly replaceState: (state: mixed, unused: string, url: string) => void,
|
|
146
|
+
...
|
|
147
|
+
};
|
|
148
|
+
|
|
79
149
|
/** The Network Information object, which only Chromium has. */
|
|
80
150
|
export type NetworkConnection = {
|
|
81
151
|
readonly downlink?: number,
|
|
@@ -98,6 +168,8 @@ export type NetworkConnection = {
|
|
|
98
168
|
export type BrowserWindow = {
|
|
99
169
|
readonly document: Document,
|
|
100
170
|
readonly navigator: BrowserNavigator,
|
|
171
|
+
readonly location?: ?BrowserLocation,
|
|
172
|
+
readonly history?: ?BrowserHistory,
|
|
101
173
|
readonly localStorage?: ?Storage,
|
|
102
174
|
readonly sessionStorage?: ?Storage,
|
|
103
175
|
readonly innerWidth: number,
|
|
@@ -269,6 +341,132 @@ export hook useDocumentVisible(serverValue: boolean = true): boolean {
|
|
|
269
341
|
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
270
342
|
}
|
|
271
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Everybody watching the fragment in this tab.
|
|
346
|
+
*
|
|
347
|
+
* Module-level, because neither `history.pushState` nor `history.replaceState`
|
|
348
|
+
* fires anything: a component that writes the fragment has to tell the others
|
|
349
|
+
* itself, and there is nothing in the platform that will do it. The same
|
|
350
|
+
* registry shape as `useStorage`'s, and balanced under Strict Mode for the same
|
|
351
|
+
* reason — `subscribe` adds and the cleanup it returns removes.
|
|
352
|
+
*/
|
|
353
|
+
const fragmentListeners: Set<() => void> = new Set();
|
|
354
|
+
|
|
355
|
+
/** Listen for every change to the fragment this tab can hear about. */
|
|
356
|
+
function subscribeToFragment(notify: () => void): () => void {
|
|
357
|
+
const win = browserWindow();
|
|
358
|
+
fragmentListeners.add(notify);
|
|
359
|
+
// `hashchange` covers an anchor the reader clicked and an address bar they
|
|
360
|
+
// edited; `popstate` covers back and forward, which fires only the second of
|
|
361
|
+
// the two when the entry it lands on differs by more than the fragment.
|
|
362
|
+
win?.addEventListener("hashchange", notify);
|
|
363
|
+
win?.addEventListener("popstate", notify);
|
|
364
|
+
return () => {
|
|
365
|
+
fragmentListeners.delete(notify);
|
|
366
|
+
win?.removeEventListener("hashchange", notify);
|
|
367
|
+
win?.removeEventListener("popstate", notify);
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The fragment, without its `#`, decoded where the escapes are valid. */
|
|
372
|
+
function readFragment(): string {
|
|
373
|
+
const raw = browserWindow()?.location?.hash ?? "";
|
|
374
|
+
const text = raw.startsWith("#") ? raw.slice(1) : raw;
|
|
375
|
+
try {
|
|
376
|
+
return decodeURIComponent(text);
|
|
377
|
+
} catch {
|
|
378
|
+
// A fragment is whatever somebody typed into the address bar, and a lone
|
|
379
|
+
// `%` is not a reason to fail a render. The text as written is closer to
|
|
380
|
+
// what the reader meant than a throw is. `@uniflowed/web`'s cookie parser
|
|
381
|
+
// makes the same trade at the same kind of boundary.
|
|
382
|
+
return text;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** What a server render, and the client's hydrating render, both report. */
|
|
387
|
+
function noFragment(): string {
|
|
388
|
+
return "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Write the fragment, without moving the page.
|
|
393
|
+
*
|
|
394
|
+
* Through `URL` rather than by concatenation, which is the whole safety
|
|
395
|
+
* argument: the setter percent-escapes what it is handed and can only put it
|
|
396
|
+
* after the `#`, so a caller who passes `?admin=1`, `//elsewhere.example` or a
|
|
397
|
+
* whole second URL writes a fragment that says so rather than a query, an
|
|
398
|
+
* origin or a path. `history.pushState` refuses a cross-origin URL, but that is
|
|
399
|
+
* the second line of defence and not one worth relying on for a same-origin
|
|
400
|
+
* path rewrite, which it permits.
|
|
401
|
+
*
|
|
402
|
+
* Module-level, so it is the same function on every render and nothing has to
|
|
403
|
+
* memoize it.
|
|
404
|
+
*/
|
|
405
|
+
function writeFragment(next: string, options?: {| readonly replace?: boolean |}): void {
|
|
406
|
+
const win = browserWindow();
|
|
407
|
+
const location = win?.location;
|
|
408
|
+
const history = win?.history;
|
|
409
|
+
if (location == null || history == null) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const url = new URL(location.href);
|
|
413
|
+
url.hash = next;
|
|
414
|
+
// `history.state` is handed back rather than cleared: a router put it there,
|
|
415
|
+
// and changing which tab is showing is not a reason to lose it.
|
|
416
|
+
if (options?.replace === true) {
|
|
417
|
+
history.replaceState(history.state, "", url.href);
|
|
418
|
+
} else {
|
|
419
|
+
history.pushState(history.state, "", url.href);
|
|
420
|
+
}
|
|
421
|
+
notifyFragment();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function notifyFragment(): void {
|
|
425
|
+
for (const listener of fragmentListeners) {
|
|
426
|
+
listener();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The fragment in the address bar, and a way to change it.
|
|
432
|
+
*
|
|
433
|
+
* The one part of the URL that needs a hook. A request carries the path and the
|
|
434
|
+
* query, so a server render already knows both and `@uniflowed/router`'s
|
|
435
|
+
* `useRoute` has resolved them; the fragment is never sent — the browser strips
|
|
436
|
+
* it before the request goes out — so there is no value for a server to know
|
|
437
|
+
* and no caller who could supply a better one. That is why this takes no
|
|
438
|
+
* `serverValue` where `useMediaQuery` and `useOnline` do: `""` is not a default
|
|
439
|
+
* chosen for want of a better one, it is the only honest answer, and the
|
|
440
|
+
* client's hydrating render reports it too before re-rendering with the truth.
|
|
441
|
+
*
|
|
442
|
+
* Returned without the `#`, and percent-decoded, so it compares directly
|
|
443
|
+
* against the `id` a section was given.
|
|
444
|
+
*
|
|
445
|
+
* Writing does not scroll, and that is deliberate. Moving to a section and
|
|
446
|
+
* recording which tab is open are two intentions, and the platform couples them
|
|
447
|
+
* only because assigning `location.hash` is the old way to do both at once — so
|
|
448
|
+
* a tab strip that writes `billing` would jump the page. Call
|
|
449
|
+
* `element.scrollIntoView()` where scrolling is what was wanted.
|
|
450
|
+
*
|
|
451
|
+
* `replace` overwrites the current history entry instead of adding one, which
|
|
452
|
+
* is what a tab strip wants: eleven tab clicks should not be eleven presses of
|
|
453
|
+
* the back button.
|
|
454
|
+
*
|
|
455
|
+
* What this cannot see is a fragment some other code changed with
|
|
456
|
+
* `history.pushState`, because that fires no event of any kind — not
|
|
457
|
+
* `hashchange`, not `popstate`. Writes made through this hook announce
|
|
458
|
+
* themselves to every other component using it; a `pushState` made anywhere
|
|
459
|
+
* else is invisible to every listener the platform offers, and naming that is
|
|
460
|
+
* more use than pretending otherwise.
|
|
461
|
+
*/
|
|
462
|
+
export hook useHash(): [
|
|
463
|
+
string,
|
|
464
|
+
(next: string, options?: {| readonly replace?: boolean |}) => void,
|
|
465
|
+
] {
|
|
466
|
+
const fragment = useSyncExternalStore(subscribeToFragment, readFragment, noFragment);
|
|
467
|
+
return [fragment, writeFragment];
|
|
468
|
+
}
|
|
469
|
+
|
|
272
470
|
/**
|
|
273
471
|
* How far something has been scrolled.
|
|
274
472
|
*
|
|
@@ -429,16 +627,35 @@ export hook useScrollLock(locked: boolean): void {
|
|
|
429
627
|
/** How good the connection is, as the browser grades it. */
|
|
430
628
|
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
|
|
431
629
|
|
|
432
|
-
/**
|
|
433
|
-
|
|
434
|
-
|
|
630
|
+
/**
|
|
631
|
+
* What Network Information measured, where there is such a thing.
|
|
632
|
+
*
|
|
633
|
+
* Its own type rather than three more fields on [`Network`], because the three
|
|
634
|
+
* of them arrive together or not at all: they come from `navigator.connection`,
|
|
635
|
+
* which is Chromium's alone. A server, and every other browser, has no object
|
|
636
|
+
* to read them off.
|
|
637
|
+
*/
|
|
638
|
+
export type NetworkMeasurement = {|
|
|
435
639
|
/** Estimated bandwidth in megabits per second, where the browser reports it. */
|
|
436
640
|
readonly downlink: number | null,
|
|
437
641
|
readonly effectiveType: EffectiveConnectionType | null,
|
|
438
642
|
/** Whether the reader has asked for less data to be used. */
|
|
439
643
|
readonly saveData: boolean,
|
|
440
|
-
|
|
441
|
-
|
|
644
|
+
|};
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* What the browser will say about the connection.
|
|
648
|
+
*
|
|
649
|
+
* `measured` used to be three flat fields and a `supported` boolean beside
|
|
650
|
+
* them, which is the shape this package now refuses: a caller had to read one
|
|
651
|
+
* field to learn whether three others meant anything, and `downlink: null` said
|
|
652
|
+
* both "this browser does not measure bandwidth" and "it does, and has not
|
|
653
|
+
* decided yet". A null here says one thing — nothing on this side can answer —
|
|
654
|
+
* and the fields that would have been guesses are not reachable to be read.
|
|
655
|
+
*/
|
|
656
|
+
export type Network = {|
|
|
657
|
+
readonly online: boolean,
|
|
658
|
+
readonly measured: NetworkMeasurement | null,
|
|
442
659
|
|};
|
|
443
660
|
|
|
444
661
|
const EFFECTIVE_TYPES: $ReadOnlyArray<EffectiveConnectionType> = ["slow-2g", "2g", "3g", "4g"];
|
|
@@ -455,10 +672,11 @@ function asEffectiveType(value: string): EffectiveConnectionType | null {
|
|
|
455
672
|
/**
|
|
456
673
|
* What the browser will say about the connection.
|
|
457
674
|
*
|
|
458
|
-
* Only Chromium implements Network Information, so
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
675
|
+
* Only Chromium implements Network Information, so a browser that does not
|
|
676
|
+
* reports `measured: null` rather than three fields that are indistinguishable
|
|
677
|
+
* from a slow connection. `online` stays flat and is answered everywhere — it
|
|
678
|
+
* is the field almost every caller wants, and burying it behind a narrowing
|
|
679
|
+
* would have made the common case pay for the rare one.
|
|
462
680
|
*
|
|
463
681
|
* The snapshot is a packed string for the reason `useWindowSize` gives: an
|
|
464
682
|
* object rebuilt on every check never compares equal, and `useSyncExternalStore`
|
|
@@ -481,39 +699,49 @@ export hook useNetwork(serverValue: boolean = true): Network {
|
|
|
481
699
|
};
|
|
482
700
|
}, []);
|
|
483
701
|
|
|
484
|
-
|
|
702
|
+
// Whether there was a connection object to read is its own field, because
|
|
703
|
+
// "this browser does not measure" and "it measures and reported nothing yet"
|
|
704
|
+
// pack to the same three empty values and are not the same answer.
|
|
705
|
+
// `effectiveType` is last so that it is the only field a `|` in browser text
|
|
706
|
+
// could run into.
|
|
707
|
+
const server = useCallback(() => `${serverValue ? "1" : "0"}|0|0||`, [serverValue]);
|
|
485
708
|
|
|
486
709
|
const packed = useSyncExternalStore(
|
|
487
710
|
subscribe,
|
|
488
711
|
useCallback(() => {
|
|
489
712
|
const navigator = browserWindow()?.navigator;
|
|
490
713
|
if (navigator == null) {
|
|
491
|
-
return `${serverValue ? "1" : "0"}
|
|
714
|
+
return `${serverValue ? "1" : "0"}|0|0||`;
|
|
492
715
|
}
|
|
493
716
|
const connection = navigator.connection;
|
|
494
717
|
const online = navigator.onLine ?? serverValue;
|
|
495
718
|
if (connection == null) {
|
|
496
|
-
return `${online ? "1" : "0"}
|
|
719
|
+
return `${online ? "1" : "0"}|0|0||`;
|
|
497
720
|
}
|
|
498
721
|
const downlink = connection.downlink;
|
|
499
722
|
return [
|
|
500
723
|
online ? "1" : "0",
|
|
724
|
+
"1",
|
|
725
|
+
connection.saveData === true ? "1" : "0",
|
|
501
726
|
downlink == null ? "" : String(downlink),
|
|
502
727
|
connection.effectiveType ?? "",
|
|
503
|
-
connection.saveData === true ? "1" : "0",
|
|
504
728
|
].join("|");
|
|
505
729
|
}, [serverValue]),
|
|
506
730
|
server,
|
|
507
731
|
);
|
|
508
732
|
|
|
509
733
|
return useMemo(() => {
|
|
510
|
-
const [online, downlink, effectiveType
|
|
734
|
+
const [online, measured, saveData, downlink, effectiveType] = packed.split("|");
|
|
511
735
|
return {
|
|
512
736
|
online: online === "1",
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
737
|
+
measured:
|
|
738
|
+
measured === "1"
|
|
739
|
+
? {
|
|
740
|
+
downlink: downlink === "" ? null : Number(downlink),
|
|
741
|
+
effectiveType: asEffectiveType(effectiveType),
|
|
742
|
+
saveData: saveData === "1",
|
|
743
|
+
}
|
|
744
|
+
: null,
|
|
517
745
|
};
|
|
518
746
|
}, [packed]);
|
|
519
747
|
}
|
|
@@ -527,12 +755,47 @@ export type Geoposition = {|
|
|
|
527
755
|
readonly timestamp: number,
|
|
528
756
|
|};
|
|
529
757
|
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|}
|
|
758
|
+
/**
|
|
759
|
+
* What `useGeolocation` knows so far.
|
|
760
|
+
*
|
|
761
|
+
* A union rather than a record of nullable fields, and the difference is the
|
|
762
|
+
* whole point of the type. The record it replaced —
|
|
763
|
+
* `{| position: Geoposition | null, error: Error | null, supported: boolean |}`
|
|
764
|
+
* — could represent eight states, of which four could never happen, and it
|
|
765
|
+
* asked every caller to work out from three fields which of the four real ones
|
|
766
|
+
* they were in. `position == null` meant "no browser", "not asked", "asked and
|
|
767
|
+
* refused" and "waiting for the reader to decide", and a page that wanted to
|
|
768
|
+
* say something different for each had to reconstruct the distinction the hook
|
|
769
|
+
* had thrown away.
|
|
770
|
+
*
|
|
771
|
+
* Five states, each of which a page does something different about, and Flow
|
|
772
|
+
* refuses to read a field the state does not have.
|
|
773
|
+
*/
|
|
774
|
+
export type GeolocationReading =
|
|
775
|
+
/**
|
|
776
|
+
* Not watching, because the caller passed `enabled: false`. Reported before
|
|
777
|
+
* `"unsupported"` is even considered, so it is the same on both sides of a
|
|
778
|
+
* hydration.
|
|
779
|
+
*/
|
|
780
|
+
| {| readonly status: "idle" |}
|
|
781
|
+
/** No geolocation object here at all: a server render, or a browser without one. */
|
|
782
|
+
| {| readonly status: "unsupported" |}
|
|
783
|
+
/** Watching. The reader has been asked and has not answered yet. */
|
|
784
|
+
| {| readonly status: "pending" |}
|
|
785
|
+
/**
|
|
786
|
+
* A fix. `error` is the failure of a *later* reading, and its presence means
|
|
787
|
+
* the position beside it is the last good one rather than the current one.
|
|
788
|
+
*/
|
|
789
|
+
| {| readonly status: "located", readonly position: Geoposition, readonly error: Error | null |}
|
|
790
|
+
/** Refused, unavailable or timed out, with no earlier fix to fall back on. */
|
|
791
|
+
| {| readonly status: "failed", readonly error: Error |};
|
|
792
|
+
|
|
793
|
+
// The three states with nothing in them, allocated once. A hook that returns a
|
|
794
|
+
// fresh object for "nothing has happened" makes every caller's dependency array
|
|
795
|
+
// change on every render.
|
|
796
|
+
const UNSUPPORTED: GeolocationReading = { status: "unsupported" };
|
|
797
|
+
const IDLE: GeolocationReading = { status: "idle" };
|
|
798
|
+
const PENDING: GeolocationReading = { status: "pending" };
|
|
536
799
|
|
|
537
800
|
/**
|
|
538
801
|
* Watch where the reader is.
|
|
@@ -541,8 +804,14 @@ export type GeolocationReading = {|
|
|
|
541
804
|
* stylistic: there is no snapshot to read. The browser has no "current
|
|
542
805
|
* position" property to ask — the first value arrives in a callback, after a
|
|
543
806
|
* permission prompt the reader may take a minute to answer or never answer at
|
|
544
|
-
* all. So
|
|
545
|
-
*
|
|
807
|
+
* all. So there is nothing but `"unsupported"` to report during a prerender and
|
|
808
|
+
* in the client's first render, which is also what makes it hydration-safe: the
|
|
809
|
+
* server writes the markup for a page that does not know where anybody is, and
|
|
810
|
+
* the hydrating render agrees with it.
|
|
811
|
+
*
|
|
812
|
+
* Turning `enabled` off after a fix reports `"idle"` rather than keeping the
|
|
813
|
+
* position, because a reading nobody is watching is a reading nobody should be
|
|
814
|
+
* shown. A caller who wants the last one to stay on screen holds it.
|
|
546
815
|
*
|
|
547
816
|
* Mounting this asks the reader for permission. Mount it on the page that
|
|
548
817
|
* needs a position, not at the top of an application.
|
|
@@ -591,10 +860,28 @@ export hook useGeolocation(options?: {|
|
|
|
591
860
|
return () => geolocation.clearWatch(watch);
|
|
592
861
|
}, [enabled, highAccuracy, maximumAge, timeout]);
|
|
593
862
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
863
|
+
// Derived during the render from what the effect has recorded, rather than
|
|
864
|
+
// kept as a second copy of it in state: a status that had to be written by
|
|
865
|
+
// the same `setReading` call would be a second thing to keep in step with
|
|
866
|
+
// `enabled` and with `supported`, neither of which the effect can see.
|
|
867
|
+
return useMemo(() => {
|
|
868
|
+
// `enabled` is asked about before `supported`, because it is the caller's
|
|
869
|
+
// own decision and is therefore the same on both sides of a hydration: a
|
|
870
|
+
// watch nobody asked for is idle on a server and idle in a browser, where
|
|
871
|
+
// "unsupported" would have been true on the server and false a moment
|
|
872
|
+
// later.
|
|
873
|
+
if (!enabled) {
|
|
874
|
+
return IDLE;
|
|
875
|
+
}
|
|
876
|
+
if (!supported) {
|
|
877
|
+
return UNSUPPORTED;
|
|
878
|
+
}
|
|
879
|
+
const { position, error } = reading;
|
|
880
|
+
if (position != null) {
|
|
881
|
+
return { status: "located", position, error };
|
|
882
|
+
}
|
|
883
|
+
return error == null ? PENDING : { status: "failed", error };
|
|
884
|
+
}, [supported, enabled, reading]);
|
|
598
885
|
}
|
|
599
886
|
|
|
600
887
|
/** A permission this hook knows how to ask about. */
|
package/events.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/events`: a stream the server is pushing.
|
|
4
|
+
//
|
|
5
|
+
// One hook, and it is here rather than in `./channels.js` for a reason that
|
|
6
|
+
// module's header states from the other side: what belongs there is a value
|
|
7
|
+
// crossing the page's boundary that is *not* the server, because a request is
|
|
8
|
+
// `@uniflowed/query`'s and `@uniflowed/fetch`'s and comes with caching, retry
|
|
9
|
+
// and revalidation that a broadcast does not want. An event stream is the
|
|
10
|
+
// server and has none of that either — there is no cache entry, nothing to
|
|
11
|
+
// revalidate, and no request to repeat. It is a connection, which is a third
|
|
12
|
+
// thing, so it is a third file.
|
|
13
|
+
//
|
|
14
|
+
// `@uniflowed/server/events` is the other end of it.
|
|
15
|
+
//
|
|
16
|
+
// # What the platform already does, and the one thing it does not
|
|
17
|
+
//
|
|
18
|
+
// `EventSource` reconnects. It backs off, it sends the last `id:` it saw back
|
|
19
|
+
// as `Last-Event-ID`, and it needs no help — for as long as the server keeps
|
|
20
|
+
// answering. There is exactly one case where it gives up permanently: a
|
|
21
|
+
// response that is not a `200` with `content-type: text/event-stream`. Then it
|
|
22
|
+
// fires `error`, moves to `CLOSED`, and never tries again. A deployment
|
|
23
|
+
// restarting behind a load balancer answers `502` for a second or two, and
|
|
24
|
+
// every open stream in every tab is dead until somebody reloads the page.
|
|
25
|
+
//
|
|
26
|
+
// That is the case this hook exists for. When the browser gives up, it opens a
|
|
27
|
+
// new `EventSource` on a doubling delay; when the browser is retrying on its
|
|
28
|
+
// own it stays out of the way, because two reconnection schedules on one
|
|
29
|
+
// connection is worse than either.
|
|
30
|
+
//
|
|
31
|
+
// The other half is that none of the platform's reconnecting is visible. An
|
|
32
|
+
// application that wants to draw "reconnecting…" cannot ask `EventSource`
|
|
33
|
+
// anything but `readyState`, and only from an event handler. [`status`] is that
|
|
34
|
+
// as state.
|
|
35
|
+
//
|
|
36
|
+
// # What a reconnection this hook makes cannot carry
|
|
37
|
+
//
|
|
38
|
+
// `Last-Event-ID`. The browser sends it on its *own* retry and there is no way
|
|
39
|
+
// to set it on a new `EventSource` — the constructor takes a URL and one flag.
|
|
40
|
+
// So [`lastEventId`] is handed back, and a stream that needs to resume puts it
|
|
41
|
+
// in the URL itself:
|
|
42
|
+
//
|
|
43
|
+
// const stream = useEventSource(`/api/feed?after=${cursor ?? ""}`);
|
|
44
|
+
//
|
|
45
|
+
// Written down rather than worked around: a hook that silently appended a query
|
|
46
|
+
// parameter would be inventing a protocol on the application's behalf.
|
|
47
|
+
//
|
|
48
|
+
// # Before hydration
|
|
49
|
+
//
|
|
50
|
+
// `status` is `"idle"` on the server and on the client's first render, through
|
|
51
|
+
// `useSupported` — see the reason there. Nothing is opened during a render; the
|
|
52
|
+
// connection is made in an effect, so a prerender neither connects nor pretends
|
|
53
|
+
// it did. `"idle"` rather than `"connecting"` because they are different facts
|
|
54
|
+
// and only one of them is true: nothing has been attempted here.
|
|
55
|
+
|
|
56
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
57
|
+
|
|
58
|
+
import { useSupported } from "./browser.js";
|
|
59
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The part of `EventSource` this module uses.
|
|
63
|
+
*
|
|
64
|
+
* Declared here rather than taken from Flow's library definition, for the
|
|
65
|
+
* reason `./channels.js` gives about `BroadcastChannel`: the constructor is
|
|
66
|
+
* looked up at runtime and may be absent, so it has to be a value with a known
|
|
67
|
+
* constructor signature, and the listener is typed with the two fields that are
|
|
68
|
+
* read — which removes an `instanceof MessageEvent` whose name is not defined
|
|
69
|
+
* in every host these tests run in.
|
|
70
|
+
*/
|
|
71
|
+
type StreamEvent = { data?: mixed, lastEventId?: mixed, ... };
|
|
72
|
+
|
|
73
|
+
declare class Stream {
|
|
74
|
+
constructor(url: string, options?: { withCredentials?: boolean, ... }): void;
|
|
75
|
+
readonly readyState: number;
|
|
76
|
+
close(): void;
|
|
77
|
+
addEventListener(type: string, listener: (event: StreamEvent) => mixed): void;
|
|
78
|
+
removeEventListener(type: string, listener: (event: StreamEvent) => mixed): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `EventSource`, which is the host's rather than the document's.
|
|
83
|
+
*
|
|
84
|
+
* The same split `./channels.js` describes: in a browser the two are one
|
|
85
|
+
* object, and in a process where a document has been installed onto another
|
|
86
|
+
* runtime's global they are not — an event stream is a facility of the runtime,
|
|
87
|
+
* and a hosted `Window` is not required to carry one.
|
|
88
|
+
*/
|
|
89
|
+
declare var EventSource: Class<Stream> | void;
|
|
90
|
+
|
|
91
|
+
function streamConstructor(): Class<Stream> | null {
|
|
92
|
+
return typeof EventSource === "undefined" ? null : (EventSource ?? null);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `EventSource.CLOSED`, spelled as the number so no global is read for it. */
|
|
96
|
+
const CLOSED = 2;
|
|
97
|
+
|
|
98
|
+
/** Where the connection is. */
|
|
99
|
+
export type EventStreamStatus =
|
|
100
|
+
/** Nothing has been attempted: a prerender, no URL, or `enabled: false`. */
|
|
101
|
+
| "idle"
|
|
102
|
+
/** Opening, or reopening — by the browser or by this hook. */
|
|
103
|
+
| "connecting"
|
|
104
|
+
/** Receiving. */
|
|
105
|
+
| "open"
|
|
106
|
+
/** Closed on purpose, by `close()`. It will not reopen on its own. */
|
|
107
|
+
| "closed";
|
|
108
|
+
|
|
109
|
+
/** One message, as the three fields the format carries. */
|
|
110
|
+
export type ServerEvent = {|
|
|
111
|
+
/** The `event:` name, or `message` where the stream sent none. */
|
|
112
|
+
readonly name: string,
|
|
113
|
+
/** The `data:` payload, as text. */
|
|
114
|
+
readonly data: string,
|
|
115
|
+
/** The `id:`, or `null`. */
|
|
116
|
+
readonly id: string | null,
|
|
117
|
+
|};
|
|
118
|
+
|
|
119
|
+
/** How the connection behaves. */
|
|
120
|
+
export type EventSourceOptions = {|
|
|
121
|
+
/**
|
|
122
|
+
* Named events to listen for, besides the unnamed `message`.
|
|
123
|
+
*
|
|
124
|
+
* A stream that sends `event: progress` delivers nothing to a `message`
|
|
125
|
+
* listener, which is the single most common way an event stream appears to
|
|
126
|
+
* be connected and silent.
|
|
127
|
+
*/
|
|
128
|
+
readonly events?: $ReadOnlyArray<string>,
|
|
129
|
+
/** Send cookies to another origin. Same-origin streams do not need it. */
|
|
130
|
+
readonly withCredentials?: boolean,
|
|
131
|
+
/** `false` closes the connection and opens none; `null` for the URL does too. */
|
|
132
|
+
readonly enabled?: boolean,
|
|
133
|
+
/** Called for every event, for a stream whose messages are events. */
|
|
134
|
+
readonly onEvent?: (event: ServerEvent) => mixed,
|
|
135
|
+
/** Milliseconds before this hook's first reopen; doubles. Default 1000. */
|
|
136
|
+
readonly retryDelay?: number,
|
|
137
|
+
/** The ceiling the doubling stops at. Default 30000. */
|
|
138
|
+
readonly maxRetryDelay?: number,
|
|
139
|
+
|};
|
|
140
|
+
|
|
141
|
+
/** What `useEventSource` hands back. */
|
|
142
|
+
export type UseEventSourceReturn = {|
|
|
143
|
+
readonly status: EventStreamStatus,
|
|
144
|
+
/**
|
|
145
|
+
* The most recent event, or `null` before one arrives.
|
|
146
|
+
*
|
|
147
|
+
* `./channels.js` deliberately does not hand back the last broadcast, and
|
|
148
|
+
* the argument there is that a message is an event: a tab that just opened
|
|
149
|
+
* has never received one and cannot ask. A stream is not that. It is a
|
|
150
|
+
* connection with a lifetime, and for as long as it is open "the latest
|
|
151
|
+
* message" is defined — which is what a progress bar, a price and a presence
|
|
152
|
+
* indicator all are. `onEvent` is there for the streams that really are
|
|
153
|
+
* events.
|
|
154
|
+
*/
|
|
155
|
+
readonly last: ServerEvent | null,
|
|
156
|
+
/**
|
|
157
|
+
* The last `id:` seen, for a stream that resumes.
|
|
158
|
+
*
|
|
159
|
+
* The browser sends this itself when *it* reconnects. It is here for the
|
|
160
|
+
* reconnection this hook makes, which cannot; see the module header.
|
|
161
|
+
*/
|
|
162
|
+
readonly lastEventId: string | null,
|
|
163
|
+
/** Close it. Nothing reopens until `enabled` or the URL changes. */
|
|
164
|
+
readonly close: () => void,
|
|
165
|
+
/** Whether this runtime has `EventSource` at all. */
|
|
166
|
+
readonly supported: boolean,
|
|
167
|
+
|};
|
|
168
|
+
|
|
169
|
+
const DEFAULT_RETRY = 1_000;
|
|
170
|
+
const DEFAULT_MAX_RETRY = 30_000;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Subscribe to a server-sent event stream.
|
|
174
|
+
*
|
|
175
|
+
* `url` is re-read when it changes, and a changed URL is a new connection —
|
|
176
|
+
* which is what makes the resume in the module header work at all. Pass `null`
|
|
177
|
+
* to connect to nothing, which is how a stream that depends on something not
|
|
178
|
+
* loaded yet waits without a second hook.
|
|
179
|
+
*/
|
|
180
|
+
export hook useEventSource(url: string | null, options?: EventSourceOptions): UseEventSourceReturn {
|
|
181
|
+
const supported = useSupported(() => streamConstructor() != null);
|
|
182
|
+
const [status, setStatus] = useState<EventStreamStatus>("idle");
|
|
183
|
+
const [last, setLast] = useState<ServerEvent | null>(null);
|
|
184
|
+
const [lastEventId, setLastEventId] = useState<string | null>(null);
|
|
185
|
+
// Bumped to ask for a new connection, which is the only thing that can
|
|
186
|
+
// reopen one the browser has given up on: `EventSource` in `CLOSED` cannot
|
|
187
|
+
// be restarted, so the effect has to run again with a new object.
|
|
188
|
+
const [attempt, setAttempt] = useState(0);
|
|
189
|
+
const [stopped, setStopped] = useState(false);
|
|
190
|
+
|
|
191
|
+
const onEvent = options?.onEvent;
|
|
192
|
+
const stable = useStableCallback((event: ServerEvent) => {
|
|
193
|
+
onEvent?.(event);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// The names are carried as one string rather than as the array the caller
|
|
197
|
+
// passed, and rebuilt inside the effect from it. An array literal in a
|
|
198
|
+
// render is a new array every time, so depending on one would tear the
|
|
199
|
+
// connection down on every keystroke anywhere in the component — and a ref
|
|
200
|
+
// written during a render is the other way to get this wrong, which this
|
|
201
|
+
// package's header rules out.
|
|
202
|
+
const key = (options?.events ?? []).join(",");
|
|
203
|
+
const withCredentials = options?.withCredentials === true;
|
|
204
|
+
const retryDelay = options?.retryDelay ?? DEFAULT_RETRY;
|
|
205
|
+
const maxRetryDelay = options?.maxRetryDelay ?? DEFAULT_MAX_RETRY;
|
|
206
|
+
// How many times *this hook* has reopened since the last success. Not
|
|
207
|
+
// `attempt`, which also moves for a URL change, and not state, because
|
|
208
|
+
// resetting it on `open` must not be a render.
|
|
209
|
+
const backoff = useRef(0);
|
|
210
|
+
|
|
211
|
+
const enabled = (options?.enabled ?? true) && !stopped;
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const Constructor = streamConstructor();
|
|
215
|
+
if (Constructor == null || url == null || !enabled) {
|
|
216
|
+
setStatus(stopped ? "closed" : "idle");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const source = new Constructor(url, { withCredentials });
|
|
221
|
+
setStatus("connecting");
|
|
222
|
+
let reopen: TimeoutID | null = null;
|
|
223
|
+
|
|
224
|
+
const onOpen = () => {
|
|
225
|
+
// The delay is reset here rather than on the first message: a stream
|
|
226
|
+
// that connects and sends nothing for an hour has still connected, and
|
|
227
|
+
// treating it as a failure would put the next real outage at the top of
|
|
228
|
+
// the backoff.
|
|
229
|
+
backoff.current = 0;
|
|
230
|
+
setStatus("open");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const onMessage = (name: string) => (event: StreamEvent) => {
|
|
234
|
+
const sent = event.lastEventId;
|
|
235
|
+
const id = typeof sent === "string" && sent !== "" ? sent : null;
|
|
236
|
+
const received: ServerEvent = { name, data: String(event.data ?? ""), id };
|
|
237
|
+
setLast(received);
|
|
238
|
+
if (id != null) {
|
|
239
|
+
setLastEventId(id);
|
|
240
|
+
}
|
|
241
|
+
stable(received);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const onError = () => {
|
|
245
|
+
if (source.readyState !== CLOSED) {
|
|
246
|
+
// The browser is retrying on its own, with `Last-Event-ID`, which is
|
|
247
|
+
// strictly better than anything this hook can do. Say so and wait.
|
|
248
|
+
setStatus("connecting");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// It has given up — a non-200, or a body that was not an event stream.
|
|
252
|
+
// Nothing reopens this object, so a new one is asked for on a delay.
|
|
253
|
+
setStatus("connecting");
|
|
254
|
+
const wait = Math.min(retryDelay * 2 ** backoff.current, maxRetryDelay);
|
|
255
|
+
backoff.current += 1;
|
|
256
|
+
reopen = setTimeout(() => setAttempt((count) => count + 1), wait);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const listeners: Array<[string, (event: StreamEvent) => mixed]> = [
|
|
260
|
+
["message", onMessage("message")],
|
|
261
|
+
];
|
|
262
|
+
for (const name of key === "" ? [] : key.split(",")) {
|
|
263
|
+
listeners.push([name, onMessage(name)]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
source.addEventListener("open", onOpen);
|
|
267
|
+
source.addEventListener("error", onError);
|
|
268
|
+
for (const [name, listener] of listeners) {
|
|
269
|
+
source.addEventListener(name, listener);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return () => {
|
|
273
|
+
if (reopen != null) {
|
|
274
|
+
clearTimeout(reopen);
|
|
275
|
+
}
|
|
276
|
+
source.removeEventListener("open", onOpen);
|
|
277
|
+
source.removeEventListener("error", onError);
|
|
278
|
+
for (const [name, listener] of listeners) {
|
|
279
|
+
source.removeEventListener(name, listener);
|
|
280
|
+
}
|
|
281
|
+
// Closed as well as unsubscribed: an `EventSource` nobody is listening
|
|
282
|
+
// to still holds a connection and still reconnects, so leaving it open
|
|
283
|
+
// is a socket per navigation for the life of the tab.
|
|
284
|
+
source.close();
|
|
285
|
+
};
|
|
286
|
+
// `key` stands in for `options.events`, and `attempt` is what a reopen
|
|
287
|
+
// moves. `backoff` is a ref, and `stable` never changes identity.
|
|
288
|
+
}, [url, enabled, stopped, key, attempt, retryDelay, maxRetryDelay, withCredentials, stable]);
|
|
289
|
+
|
|
290
|
+
const close = useCallback(() => {
|
|
291
|
+
setStopped(true);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
return useMemo(
|
|
295
|
+
() => ({ status, last, lastEventId, close, supported }),
|
|
296
|
+
[status, last, lastEventId, close, supported],
|
|
297
|
+
);
|
|
298
|
+
}
|
package/index.js
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
// its sidebar under 48rem wants `false` on the server and one that renders a
|
|
33
33
|
// mobile menu wants `true`, and a library cannot know which.
|
|
34
34
|
//
|
|
35
|
+
// Where there is exactly one honest answer, the caller is not asked. `useHash`
|
|
36
|
+
// takes no server value because the browser strips the fragment before the
|
|
37
|
+
// request goes out, so `""` is not a default standing in for something better —
|
|
38
|
+
// it is what the server knows, and offering an override would have invited a
|
|
39
|
+
// caller to state a value that cannot be true.
|
|
40
|
+
//
|
|
35
41
|
// The hooks built on effects rather than stores — everything in `dom.js`, and
|
|
36
42
|
// the three in `browser.js` whose first value only arrives in a callback — do
|
|
37
43
|
// nothing at all before hydration, and report the same starting value on both
|
|
@@ -50,7 +56,7 @@
|
|
|
50
56
|
//
|
|
51
57
|
// # How the package is laid out
|
|
52
58
|
//
|
|
53
|
-
//
|
|
59
|
+
// Nine modules beside this one, split by what a hook's subject is — because
|
|
54
60
|
// that is the question a reader looking for one actually asks:
|
|
55
61
|
//
|
|
56
62
|
// - `lifecycle.js` — the component itself: mounted, previous, run once.
|
|
@@ -58,14 +64,22 @@
|
|
|
58
64
|
// toggle, counter, list, set, cycle, undo/redo, storage.
|
|
59
65
|
// - `timing.js` — when something runs: intervals, timeouts, debounce,
|
|
60
66
|
// throttle, frames, idleness, and the clock behind "3 minutes ago".
|
|
67
|
+
// - `render.js` — what a render has to fix rather than derive: the instant it
|
|
68
|
+
// was made at, and the seed anything random on the page is drawn from.
|
|
61
69
|
// - `async.js` — one promise: its states, its abort signal, its retries.
|
|
62
70
|
// - `browser.js` — the ambient environment: viewport, scroll, connection,
|
|
63
|
-
// position, permissions, preferences.
|
|
71
|
+
// position, permissions, preferences, the fragment in the address bar. Its
|
|
72
|
+
// header carries the table of what every one of them renders before
|
|
73
|
+
// hydration.
|
|
64
74
|
// - `dom.js` — one element the caller holds a ref to: listen, measure,
|
|
65
75
|
// observe, press.
|
|
66
76
|
// - `keyboard.js` — what is being pressed: a chord, and a held key.
|
|
67
77
|
// - `channels.js` — a value that came from outside the page: another tab, the
|
|
68
78
|
// system clipboard.
|
|
79
|
+
// - `events.js` — a stream the server is pushing, and where its reconnection
|
|
80
|
+
// is. Not `channels.js`, whose boundary is deliberately everything except
|
|
81
|
+
// the server, and not `@uniflowed/query`, because there is no cache entry
|
|
82
|
+
// and nothing to revalidate: a connection is a third thing.
|
|
69
83
|
//
|
|
70
84
|
// The two that are easiest to confuse are `browser.js` and `dom.js`, so each
|
|
71
85
|
// says so in its own header: `browser.js` needs no ref because there is one
|
|
@@ -90,11 +104,14 @@
|
|
|
90
104
|
// **Implemented and tested.** The component's life; the state shapes; every
|
|
91
105
|
// timer, including the adaptive schedule behind `useTimeAgo`; `useAsync` with
|
|
92
106
|
// abort and retry; media queries, colour scheme, reduced motion, online,
|
|
93
|
-
// document visibility, window size and scroll,
|
|
107
|
+
// document visibility, window size and scroll, the address bar's fragment,
|
|
108
|
+
// scroll lock; element size,
|
|
94
109
|
// intersection, mutations, hover, focus-within, click-outside, long press,
|
|
95
110
|
// element scroll, the element as state; key chords and held keys; storage with
|
|
96
111
|
// cross-tab sync;
|
|
97
|
-
// broadcast channels; the clipboard
|
|
112
|
+
// broadcast channels; the clipboard; a server-sent event stream, including the
|
|
113
|
+
// one case the platform's own reconnection gives up on; the render anchor and
|
|
114
|
+
// the seeded stream in `render.js`. `tests/library/hooks.test.js` covers
|
|
98
115
|
// behaviour and cleanup, and `tests/library/hooks-ssr.test.js` renders the
|
|
99
116
|
// whole surface in a process that has no DOM at all.
|
|
100
117
|
//
|
|
@@ -120,6 +137,8 @@
|
|
|
120
137
|
|
|
121
138
|
export type { Async, AsyncOptions } from "./async.js";
|
|
122
139
|
export type {
|
|
140
|
+
BrowserHistory,
|
|
141
|
+
BrowserLocation,
|
|
123
142
|
BrowserNavigator,
|
|
124
143
|
BrowserWindow,
|
|
125
144
|
EffectiveConnectionType,
|
|
@@ -127,6 +146,7 @@ export type {
|
|
|
127
146
|
Geoposition,
|
|
128
147
|
Network,
|
|
129
148
|
NetworkConnection,
|
|
149
|
+
NetworkMeasurement,
|
|
130
150
|
PermissionAnswer,
|
|
131
151
|
PermissionName,
|
|
132
152
|
ScrollOffset,
|
|
@@ -134,7 +154,14 @@ export type {
|
|
|
134
154
|
} from "./browser.js";
|
|
135
155
|
export type { UseBroadcastReturn, UseClipboardReturn } from "./channels.js";
|
|
136
156
|
export type { ListenerOptions, ListenerTarget, MutationOptions, Ref } from "./dom.js";
|
|
157
|
+
export type {
|
|
158
|
+
EventSourceOptions,
|
|
159
|
+
EventStreamStatus,
|
|
160
|
+
ServerEvent,
|
|
161
|
+
UseEventSourceReturn,
|
|
162
|
+
} from "./events.js";
|
|
137
163
|
export type { KeyComboOptions } from "./keyboard.js";
|
|
164
|
+
export type { RenderEnvelope } from "./render.js";
|
|
138
165
|
export type {
|
|
139
166
|
UseCounterReturn,
|
|
140
167
|
UseCycleReturn,
|
|
@@ -169,6 +196,7 @@ export {
|
|
|
169
196
|
browserWindow,
|
|
170
197
|
useDocumentVisible,
|
|
171
198
|
useGeolocation,
|
|
199
|
+
useHash,
|
|
172
200
|
useMediaQuery,
|
|
173
201
|
useNetwork,
|
|
174
202
|
useOnline,
|
|
@@ -194,7 +222,17 @@ export {
|
|
|
194
222
|
useScroll,
|
|
195
223
|
} from "./dom.js";
|
|
196
224
|
export { useKeyCombo, useKeyHeld } from "./keyboard.js";
|
|
225
|
+
export {
|
|
226
|
+
RENDER_ID,
|
|
227
|
+
RenderProvider,
|
|
228
|
+
useRandom,
|
|
229
|
+
useRenderEnvelope,
|
|
230
|
+
useRenderTimeZone,
|
|
231
|
+
useRenderedAt,
|
|
232
|
+
useShuffled,
|
|
233
|
+
} from "./render.js";
|
|
197
234
|
export { useBroadcast, useClipboard } from "./channels.js";
|
|
235
|
+
export { useEventSource } from "./events.js";
|
|
198
236
|
export {
|
|
199
237
|
useCounter,
|
|
200
238
|
useCycle,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/hooks",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.13",
|
|
4
4
|
"description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
"./browser": "./browser.js",
|
|
17
17
|
"./channels": "./channels.js",
|
|
18
18
|
"./dom": "./dom.js",
|
|
19
|
+
"./events": "./events.js",
|
|
19
20
|
"./keyboard": "./keyboard.js",
|
|
20
21
|
"./lifecycle": "./lifecycle.js",
|
|
22
|
+
"./render": "./render.js",
|
|
21
23
|
"./state": "./state.js",
|
|
22
24
|
"./timing": "./timing.js"
|
|
23
25
|
},
|
|
@@ -25,7 +27,8 @@
|
|
|
25
27
|
"*.js"
|
|
26
28
|
],
|
|
27
29
|
"dependencies": {
|
|
28
|
-
"@uniflowed/
|
|
30
|
+
"@uniflowed/core": "0.0.0-alpha.13",
|
|
31
|
+
"@uniflowed/react": "0.0.0-alpha.13"
|
|
29
32
|
},
|
|
30
33
|
"peerDependencies": {
|
|
31
34
|
"react": ">=19"
|
package/render.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/render`: the two things a render must decide once.
|
|
4
|
+
//
|
|
5
|
+
// A page that is prerendered is rendered twice — once on a server, once in the
|
|
6
|
+
// browser that hydrates it — and React compares the two. Anything the second
|
|
7
|
+
// render works out for itself differs from the first, and the two that differ
|
|
8
|
+
// in practice are the clock and the random number generator. `new Date()` is a
|
|
9
|
+
// different instant on the two machines and `Math.random()` is a different
|
|
10
|
+
// number by construction, so a countdown, a greeting that depends on the hour,
|
|
11
|
+
// a shuffled list of featured articles and a randomly chosen placeholder are
|
|
12
|
+
// each a hydration mismatch that the application did nothing to deserve.
|
|
13
|
+
//
|
|
14
|
+
// The usual advice is to render nothing until an effect has run. That works,
|
|
15
|
+
// and it costs the page: the value is invisible to a crawler and to a reader
|
|
16
|
+
// with no JavaScript, and it arrives one frame late and moves the layout when
|
|
17
|
+
// it does.
|
|
18
|
+
//
|
|
19
|
+
// This module is the other answer. The render decides both values once, on
|
|
20
|
+
// whichever side goes first, and the other side reads what was decided instead
|
|
21
|
+
// of deciding again. Both renders then produce the same markup, because they
|
|
22
|
+
// are working from the same two numbers.
|
|
23
|
+
//
|
|
24
|
+
// # Why a provider, and not module state
|
|
25
|
+
//
|
|
26
|
+
// A clock installed in `@uniflowed/core/clock` is the process's. That is right
|
|
27
|
+
// for a test and for a runtime, and wrong for a server: a server renders
|
|
28
|
+
// several requests at once, and two responses that shared one "rendered at"
|
|
29
|
+
// would each be stamped with whenever the other one started. React's context is
|
|
30
|
+
// per-render by construction, which is the granularity this actually needs, so
|
|
31
|
+
// the values travel through the tree rather than beside it.
|
|
32
|
+
//
|
|
33
|
+
// # How the value crosses the network
|
|
34
|
+
//
|
|
35
|
+
// `RenderProvider` writes what it decided into the markup, as an inert
|
|
36
|
+
// `<script type="application/json">`, and reads it back on the client before
|
|
37
|
+
// the first render — the same carrier and the same escaping
|
|
38
|
+
// `@uniflowed/router` uses for loader data, because it is the same problem and
|
|
39
|
+
// a second mechanism would be a second thing to get wrong. `dangerouslySetInnerHTML`
|
|
40
|
+
// rather than a text child, because the HTML parser treats a `<script>` body as
|
|
41
|
+
// raw text and does not decode entities: React's escaping of `&` would survive
|
|
42
|
+
// into `JSON.parse` and fail there.
|
|
43
|
+
//
|
|
44
|
+
// A page nobody prerendered has no script to read, decides both values from the
|
|
45
|
+
// host, and is correct for the reason that there is nothing to disagree with.
|
|
46
|
+
//
|
|
47
|
+
// # What belongs in this module
|
|
48
|
+
//
|
|
49
|
+
// A value that a render has to fix rather than derive. Two so far, and they are
|
|
50
|
+
// the two React itself has an answer for exactly one of: `useId` solves ids by
|
|
51
|
+
// deriving them from the position in the tree, which works for an id and for
|
|
52
|
+
// nothing that has to be shuffled or counted from.
|
|
53
|
+
//
|
|
54
|
+
// Not here: a hook that reads a clock to schedule work. `useInterval`,
|
|
55
|
+
// `useTimeout` and `useNow` are `timing.js`'s, and they read the *current* time
|
|
56
|
+
// — this module is about the one instant that must not move.
|
|
57
|
+
|
|
58
|
+
import * as React from "@uniflowed/react";
|
|
59
|
+
import { currentClock } from "@uniflowed/core/clock";
|
|
60
|
+
import type { Random } from "@uniflowed/core/random";
|
|
61
|
+
import { hostSeed, seededRandom, shuffled } from "@uniflowed/core/random";
|
|
62
|
+
import type { Instant } from "@uniflowed/core/temporal";
|
|
63
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
64
|
+
|
|
65
|
+
/** What a render fixes, and what travels to the client. */
|
|
66
|
+
export type RenderEnvelope = {
|
|
67
|
+
/** The instant the render was anchored to, in epoch milliseconds. */
|
|
68
|
+
readonly at: number,
|
|
69
|
+
/** The IANA zone the server was in. Not the reader's — see `Time`. */
|
|
70
|
+
readonly timeZone: string,
|
|
71
|
+
/** The seed both sides replay the same numbers from. */
|
|
72
|
+
readonly seed: string,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The element the envelope is written into, and read back out of. */
|
|
76
|
+
export const RENDER_ID: string = "__uf_render";
|
|
77
|
+
|
|
78
|
+
const RenderContext: React.Context<RenderEnvelope | null> = React.createContext(null);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The envelope the server left in the document, if there is one.
|
|
82
|
+
*
|
|
83
|
+
* Read from the DOM rather than from a global an inline script assigned,
|
|
84
|
+
* because an inline script that runs is a script a content-security policy has
|
|
85
|
+
* to allow, and this value is worth no relaxation of one.
|
|
86
|
+
*/
|
|
87
|
+
function embedded(): RenderEnvelope | null {
|
|
88
|
+
const document = globalThis.document;
|
|
89
|
+
if (document == null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const element = document.getElementById(RENDER_ID);
|
|
93
|
+
if (element == null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const found = JSON.parse(element.textContent ?? "null");
|
|
98
|
+
if (found == null || typeof found.at !== "number" || typeof found.seed !== "string") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return { at: found.at, timeZone: String(found.timeZone), seed: found.seed };
|
|
102
|
+
} catch {
|
|
103
|
+
// A truncated document — a stream that was cut off — leaves half a JSON
|
|
104
|
+
// object here. Deciding fresh values is wrong on that page in exactly the
|
|
105
|
+
// way this module exists to prevent, and it is still better than a render
|
|
106
|
+
// that throws: the page renders, and hydration reports what it always
|
|
107
|
+
// would have.
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Decide what this render is anchored to, from props, from the markup, or from the host. */
|
|
113
|
+
function envelope(given: { at?: number, timeZone?: string, seed?: string }): RenderEnvelope {
|
|
114
|
+
const found = embedded();
|
|
115
|
+
const clock = currentClock();
|
|
116
|
+
return {
|
|
117
|
+
at: given.at ?? found?.at ?? clock.now(),
|
|
118
|
+
timeZone: given.timeZone ?? found?.timeZone ?? clock.timeZone(),
|
|
119
|
+
seed: given.seed ?? found?.seed ?? hostSeed(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `envelope` as the text of a `<script type="application/json">`.
|
|
125
|
+
*
|
|
126
|
+
* `<` is escaped so that a zone name or a seed holding `</script>` cannot end
|
|
127
|
+
* the element early, and the two line separators are escaped because they are
|
|
128
|
+
* newlines to a JavaScript parser and are not to `JSON.stringify`. The same
|
|
129
|
+
* three replacements `@uniflowed/router` makes, deliberately duplicated rather
|
|
130
|
+
* than shared: this package does not depend on the router, and three lines are
|
|
131
|
+
* not worth an import that would drag one in.
|
|
132
|
+
*/
|
|
133
|
+
function encode(value: RenderEnvelope): string {
|
|
134
|
+
return JSON.stringify(value)
|
|
135
|
+
.replace(/</g, "\\u003c")
|
|
136
|
+
.replace(/\u2028/g, "\\u2028")
|
|
137
|
+
.replace(/\u2029/g, "\\u2029");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Fix this render's instant, zone and seed, and hand them to the tree.
|
|
142
|
+
*
|
|
143
|
+
* Render it once, above everything that reads a clock — a root layout is where
|
|
144
|
+
* it belongs. Every argument is optional and the defaults are the whole point:
|
|
145
|
+
* a server decides, the markup carries what it decided, and the browser reads it
|
|
146
|
+
* back before its first render, so neither side has to be told which one it is.
|
|
147
|
+
*
|
|
148
|
+
* `at` and `seed` are there for the two cases that are not that. A test passes
|
|
149
|
+
* them to get a page that renders the same bytes every time; an application
|
|
150
|
+
* whose instant comes from somewhere better — a request header, a loader —
|
|
151
|
+
* passes that instead.
|
|
152
|
+
*
|
|
153
|
+
* `security/no-dangerously-set-inner-html` is suppressed on the one line that
|
|
154
|
+
* needs it, and the argument is narrow enough to state exactly. The rule is
|
|
155
|
+
* about markup that came from somewhere — a comment, a profile, a response —
|
|
156
|
+
* and its escape hatch is a `@uniflowed/markdown` sanitizer, which is the right
|
|
157
|
+
* answer for markup and no answer at all for JSON. What is written here is
|
|
158
|
+
* three fields this module produced: two of them are typed `number` and
|
|
159
|
+
* `string` and all three go through `JSON.stringify` and then `encode`, which
|
|
160
|
+
* removes the only three characters that can end a `<script>` early or split a
|
|
161
|
+
* line inside one. There is also no other spelling — the HTML parser reads a
|
|
162
|
+
* `<script>` body as raw text and does not decode entities, so React's escaping
|
|
163
|
+
* of a text child would survive into `JSON.parse` and fail there.
|
|
164
|
+
*/
|
|
165
|
+
export component RenderProvider(
|
|
166
|
+
at?: number,
|
|
167
|
+
timeZone?: string,
|
|
168
|
+
seed?: string,
|
|
169
|
+
children: React.Node,
|
|
170
|
+
) {
|
|
171
|
+
// The initializer runs once per mount, on both sides, which is what makes
|
|
172
|
+
// this a fixed value rather than a clock: a re-render for any other reason
|
|
173
|
+
// must not move the instant the page has already been drawn with.
|
|
174
|
+
const [value] = React.useState(() => envelope({ at, timeZone, seed }));
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<RenderContext.Provider value={value}>
|
|
178
|
+
<script
|
|
179
|
+
id={RENDER_ID}
|
|
180
|
+
type="application/json"
|
|
181
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
182
|
+
dangerouslySetInnerHTML={{ __html: encode(value) }}
|
|
183
|
+
/>
|
|
184
|
+
{children}
|
|
185
|
+
</RenderContext.Provider>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What this render was anchored to, or `null` outside a `RenderProvider`.
|
|
191
|
+
*
|
|
192
|
+
* Null rather than a fabricated envelope, because "nobody fixed these values"
|
|
193
|
+
* is a fact the hooks in `timing.js` act on: without a provider they read the
|
|
194
|
+
* clock, which is the behaviour they have always had.
|
|
195
|
+
*/
|
|
196
|
+
export hook useRenderEnvelope(): RenderEnvelope | null {
|
|
197
|
+
return React.useContext(RenderContext);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The instant this page was rendered at, as a `Temporal.Instant`.
|
|
202
|
+
*
|
|
203
|
+
* Constant for the life of the render, on both sides, which is what makes it
|
|
204
|
+
* safe to put in the markup. It is not "now" and does not become "now": a page
|
|
205
|
+
* left open for an hour still reports the instant it was rendered at, and a
|
|
206
|
+
* label that has to stay true while the reader looks at it is `useTimeAgo`.
|
|
207
|
+
*
|
|
208
|
+
* Falls back to the clock outside a provider, which is right for a page that is
|
|
209
|
+
* only ever rendered once — and is a hydration mismatch on one that is
|
|
210
|
+
* prerendered, which is what the provider is for.
|
|
211
|
+
*/
|
|
212
|
+
export hook useRenderedAt(): Instant {
|
|
213
|
+
const found = useRenderEnvelope();
|
|
214
|
+
const at = found?.at;
|
|
215
|
+
// The number, not the instant, in the dependency: `Instant` is a new object
|
|
216
|
+
// every render and depending on it would rebuild this on every one.
|
|
217
|
+
const clockAt = at ?? currentClock().now();
|
|
218
|
+
return React.useMemo(() => Temporal.Instant.fromEpochMilliseconds(clockAt), [clockAt]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The zone the render was made in.
|
|
223
|
+
*
|
|
224
|
+
* The server's, not the reader's, and the distinction is the second half of the
|
|
225
|
+
* hydration problem rather than a detail: markup formatted in the reader's zone
|
|
226
|
+
* cannot match markup formatted in the server's, so a component renders this one
|
|
227
|
+
* and localises after hydration. `@uniflowed/web`'s `Time` is that component.
|
|
228
|
+
*/
|
|
229
|
+
export hook useRenderTimeZone(): string {
|
|
230
|
+
const found = useRenderEnvelope();
|
|
231
|
+
return found?.timeZone ?? currentClock().timeZone();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A stream of random numbers that both renders produce identically.
|
|
236
|
+
*
|
|
237
|
+
* `label` names the stream, and naming it is what makes it independent of every
|
|
238
|
+
* other one: two components that ask for `"featured"` and `"sidebar"` get the
|
|
239
|
+
* same numbers whatever order they render in, and whatever suspends between
|
|
240
|
+
* them. Sharing one stream would make each component's numbers depend on how
|
|
241
|
+
* many the components above it happened to draw — stable in a synchronous
|
|
242
|
+
* render, and not stable once a boundary resolves at a different moment on the
|
|
243
|
+
* two sides.
|
|
244
|
+
*
|
|
245
|
+
* The stream is stateful, so a component that draws from it during render draws
|
|
246
|
+
* different numbers on a re-render. Draw in a `useMemo` keyed by what the
|
|
247
|
+
* numbers are for, or in an event, and never in the body of a component React
|
|
248
|
+
* may render twice.
|
|
249
|
+
*
|
|
250
|
+
* Outside a provider the seed is a constant rather than the host's, which looks
|
|
251
|
+
* like the wrong default and is the right one: two renders with no envelope
|
|
252
|
+
* between them still have to agree, and a constant is the only seed both of them
|
|
253
|
+
* can arrive at. What it costs is that every such page shuffles the same way,
|
|
254
|
+
* which is a reason to render a provider rather than a reason to be
|
|
255
|
+
* unpredictable here.
|
|
256
|
+
*/
|
|
257
|
+
export hook useRandom(label: string): Random {
|
|
258
|
+
const found = useRenderEnvelope();
|
|
259
|
+
const seed = found?.seed;
|
|
260
|
+
return React.useMemo(() => seededRandom(seed ?? "uf").fork(label), [seed, label]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `items`, shuffled the same way on both sides of a hydration.
|
|
265
|
+
*
|
|
266
|
+
* The shuffle is a `useMemo` over the seed, the label and the items, so it is
|
|
267
|
+
* one shuffle rather than one per render — which matters for more than speed:
|
|
268
|
+
* a fresh draw on every render would reorder the list under the reader every
|
|
269
|
+
* time anything else on the page changed.
|
|
270
|
+
*/
|
|
271
|
+
export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> {
|
|
272
|
+
const random = useRandom(label);
|
|
273
|
+
return React.useMemo(() => shuffled(items, random), [items, random]);
|
|
274
|
+
}
|
package/timing.js
CHANGED
|
@@ -32,11 +32,27 @@
|
|
|
32
32
|
// `@uniflowed/web` to get there, because a hook library that pulled in a
|
|
33
33
|
// component library would be the wrong direction for the one arrow between
|
|
34
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.
|
|
35
49
|
|
|
36
50
|
import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
51
|
+
import { currentClock } from "@uniflowed/core/clock";
|
|
37
52
|
|
|
38
53
|
import { browserWindow } from "./browser.js";
|
|
39
54
|
import { useMounted, useStableCallback } from "./lifecycle.js";
|
|
55
|
+
import { useRenderEnvelope } from "./render.js";
|
|
40
56
|
|
|
41
57
|
/**
|
|
42
58
|
* Call `body` every `millis`, or not at all when `millis` is null.
|
|
@@ -103,7 +119,7 @@ export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
|
103
119
|
const last = useRef(0);
|
|
104
120
|
|
|
105
121
|
return useStableCallback<TArgs, void>((...args: TArgs) => {
|
|
106
|
-
const now =
|
|
122
|
+
const now = currentClock().now();
|
|
107
123
|
if (now - last.current >= millis) {
|
|
108
124
|
last.current = now;
|
|
109
125
|
stable(...args);
|
|
@@ -262,26 +278,39 @@ export hook useIdle(
|
|
|
262
278
|
* bounded: it happens once, the value is never re-read during a render, and a
|
|
263
279
|
* render React throws away is replaced by another whose clock is just as valid.
|
|
264
280
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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`.
|
|
270
296
|
*/
|
|
271
297
|
export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date {
|
|
272
298
|
// The instant rather than the object: a caller writing `new Date(...)` in
|
|
273
299
|
// the call passes a different object every render, and a dependency on it
|
|
274
300
|
// would re-run the effect forever.
|
|
275
|
-
const
|
|
276
|
-
const
|
|
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
|
+
);
|
|
277
306
|
|
|
278
307
|
useEffect(() => {
|
|
279
308
|
if (since != null) {
|
|
280
|
-
setNow(new Date());
|
|
309
|
+
setNow(new Date(currentClock().now()));
|
|
281
310
|
}
|
|
282
311
|
}, [since]);
|
|
283
312
|
|
|
284
|
-
useInterval(() => setNow(new Date()), millis);
|
|
313
|
+
useInterval(() => setNow(new Date(currentClock().now())), millis);
|
|
285
314
|
return now;
|
|
286
315
|
}
|
|
287
316
|
|