@uniflowed/hooks 0.0.0-alpha.13 → 0.0.0-alpha.14
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 +85 -14
- package/index.js +1 -1
- package/package.json +3 -3
- package/render.js +151 -71
package/browser.js
CHANGED
|
@@ -77,11 +77,14 @@
|
|
|
77
77
|
// nothing for a snapshot to return until it does, and `useScrollLock` writes.
|
|
78
78
|
// Each says so where it is defined.
|
|
79
79
|
//
|
|
80
|
-
// `useHash` is a store
|
|
81
|
-
// `hashchange` and `popstate` cover what a
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
80
|
+
// `useHash` is a store with three subscriptions instead of one, because no
|
|
81
|
+
// single event covers the fragment. `hashchange` and `popstate` cover what a
|
|
82
|
+
// reader does; `history.pushState` fires neither, so a registry of its own
|
|
83
|
+
// subscribers covers what the hook itself writes; and `currententrychange`,
|
|
84
|
+
// where the Navigation API exists, covers the case neither of those reaches —
|
|
85
|
+
// a `pushState` made by other code, the router's own included. That is a store
|
|
86
|
+
// whose gap has shrunk to the browsers without the Navigation API, not a
|
|
87
|
+
// fourth kind of hook.
|
|
85
88
|
|
|
86
89
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
|
|
87
90
|
|
|
@@ -146,6 +149,29 @@ export type BrowserHistory = {
|
|
|
146
149
|
...
|
|
147
150
|
};
|
|
148
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The part of the Navigation API this module listens to.
|
|
154
|
+
*
|
|
155
|
+
* One event, and deliberately only one. `currententrychange` fires after the
|
|
156
|
+
* current history entry has changed *for any reason* — a link, the back
|
|
157
|
+
* button, and the two calls that fire nothing else, `history.pushState` and
|
|
158
|
+
* `history.replaceState`. It is the only thing the platform offers that hears
|
|
159
|
+
* a fragment written by code other than the writer, which is what makes
|
|
160
|
+
* `useHash` able to see `@uniflowed/router`'s own navigation.
|
|
161
|
+
*
|
|
162
|
+
* `navigate` and `navigateerror` are not here: intercepting a navigation is
|
|
163
|
+
* the router's business, and a hook that reads the fragment has no opinion
|
|
164
|
+
* about whether one should happen.
|
|
165
|
+
*
|
|
166
|
+
* Optional on [`BrowserWindow`], because this is an addition rather than the
|
|
167
|
+
* base — see `useHash` for which browsers have it and what the others get.
|
|
168
|
+
*/
|
|
169
|
+
export type BrowserNavigation = {
|
|
170
|
+
readonly addEventListener: (type: "currententrychange", listener: () => mixed) => void,
|
|
171
|
+
readonly removeEventListener: (type: "currententrychange", listener: () => mixed) => void,
|
|
172
|
+
...
|
|
173
|
+
};
|
|
174
|
+
|
|
149
175
|
/** The Network Information object, which only Chromium has. */
|
|
150
176
|
export type NetworkConnection = {
|
|
151
177
|
readonly downlink?: number,
|
|
@@ -170,6 +196,7 @@ export type BrowserWindow = {
|
|
|
170
196
|
readonly navigator: BrowserNavigator,
|
|
171
197
|
readonly location?: ?BrowserLocation,
|
|
172
198
|
readonly history?: ?BrowserHistory,
|
|
199
|
+
readonly navigation?: ?BrowserNavigation,
|
|
173
200
|
readonly localStorage?: ?Storage,
|
|
174
201
|
readonly sessionStorage?: ?Storage,
|
|
175
202
|
readonly innerWidth: number,
|
|
@@ -346,25 +373,45 @@ export hook useDocumentVisible(serverValue: boolean = true): boolean {
|
|
|
346
373
|
*
|
|
347
374
|
* Module-level, because neither `history.pushState` nor `history.replaceState`
|
|
348
375
|
* fires anything: a component that writes the fragment has to tell the others
|
|
349
|
-
* itself, and
|
|
350
|
-
* registry shape as `useStorage`'s, and
|
|
351
|
-
* reason — `subscribe` adds and the
|
|
376
|
+
* itself, and in a browser without the Navigation API there is nothing in the
|
|
377
|
+
* platform that will do it. The same registry shape as `useStorage`'s, and
|
|
378
|
+
* balanced under Strict Mode for the same reason — `subscribe` adds and the
|
|
379
|
+
* cleanup it returns removes.
|
|
380
|
+
*
|
|
381
|
+
* It is kept where `currententrychange` exists rather than being switched off
|
|
382
|
+
* there. The two overlap — a write through this hook is heard twice — and that
|
|
383
|
+
* costs nothing, because `useSyncExternalStore` compares the snapshot and the
|
|
384
|
+
* fragment is a string that has not changed between the two notifications.
|
|
385
|
+
* Switching it off, on the other hand, would make the hook's own writes depend
|
|
386
|
+
* on a feature detection, so a browser that reported a `navigation` object it
|
|
387
|
+
* did not fire events from would silently lose the guarantee that has held
|
|
388
|
+
* since this hook existed.
|
|
352
389
|
*/
|
|
353
390
|
const fragmentListeners: Set<() => void> = new Set();
|
|
354
391
|
|
|
355
392
|
/** Listen for every change to the fragment this tab can hear about. */
|
|
356
393
|
function subscribeToFragment(notify: () => void): () => void {
|
|
357
394
|
const win = browserWindow();
|
|
395
|
+
// Read once and closed over, so the cleanup removes the listener from the
|
|
396
|
+
// object it was added to. A `navigation` that appeared or vanished between
|
|
397
|
+
// the two would otherwise leave a listener behind on a hook that unmounted.
|
|
398
|
+
const navigation = win?.navigation;
|
|
358
399
|
fragmentListeners.add(notify);
|
|
359
400
|
// `hashchange` covers an anchor the reader clicked and an address bar they
|
|
360
401
|
// edited; `popstate` covers back and forward, which fires only the second of
|
|
361
402
|
// the two when the entry it lands on differs by more than the fragment.
|
|
362
403
|
win?.addEventListener("hashchange", notify);
|
|
363
404
|
win?.addEventListener("popstate", notify);
|
|
405
|
+
// And `currententrychange` covers the case the other two and the registry
|
|
406
|
+
// between them still miss: a `pushState` or `replaceState` made by code that
|
|
407
|
+
// is not this hook. `@uniflowed/router` makes exactly that call on every
|
|
408
|
+
// client navigation, which is why the gap was never hypothetical.
|
|
409
|
+
navigation?.addEventListener("currententrychange", notify);
|
|
364
410
|
return () => {
|
|
365
411
|
fragmentListeners.delete(notify);
|
|
366
412
|
win?.removeEventListener("hashchange", notify);
|
|
367
413
|
win?.removeEventListener("popstate", notify);
|
|
414
|
+
navigation?.removeEventListener("currententrychange", notify);
|
|
368
415
|
};
|
|
369
416
|
}
|
|
370
417
|
|
|
@@ -452,12 +499,36 @@ function notifyFragment(): void {
|
|
|
452
499
|
* is what a tab strip wants: eleven tab clicks should not be eleven presses of
|
|
453
500
|
* the back button.
|
|
454
501
|
*
|
|
455
|
-
* What
|
|
456
|
-
*
|
|
457
|
-
* `
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
502
|
+
* # What it hears, and where
|
|
503
|
+
*
|
|
504
|
+
* `history.pushState` and `history.replaceState` fire no event of any kind —
|
|
505
|
+
* not `hashchange`, not `popstate` — so what a hook can see depends on where
|
|
506
|
+
* the write came from and on what the browser has:
|
|
507
|
+
*
|
|
508
|
+
* | The write | Everywhere | Without the Navigation API |
|
|
509
|
+
* | --- | --- | --- |
|
|
510
|
+
* | the reader: an anchor, the address bar, back and forward | seen | seen |
|
|
511
|
+
* | this hook's own setter | seen | seen |
|
|
512
|
+
* | `pushState` from other code — `@uniflowed/router`'s navigation | seen | **not seen** |
|
|
513
|
+
*
|
|
514
|
+
* The first two are `hashchange`, `popstate` and the module's own registry.
|
|
515
|
+
* The third is `currententrychange`, which fires after the current history
|
|
516
|
+
* entry changes for any reason at all, and which is the only thing the
|
|
517
|
+
* platform offers that hears a write the writer did not announce.
|
|
518
|
+
*
|
|
519
|
+
* "Without the Navigation API" is now a narrow set: Chrome and Edge have had
|
|
520
|
+
* it since 102 (2022), Safari since 26.2 and Firefox since 147 — but a reader
|
|
521
|
+
* on an older Safari or Firefox is a reader this column describes, and there
|
|
522
|
+
* the registry is still the whole answer. A router navigation that changes
|
|
523
|
+
* only the fragment leaves such a page showing the section it was on.
|
|
524
|
+
*
|
|
525
|
+
* The fragment is deliberately not on `RouteInfo` — `useRoute()` cannot answer
|
|
526
|
+
* this question and should not learn to. A `RouteInfo` is what a *request*
|
|
527
|
+
* resolved to, and the browser strips the fragment before the request goes
|
|
528
|
+
* out, so a field for it would be one the server could never fill and the two
|
|
529
|
+
* renders would disagree about. This hook is the one answer, and
|
|
530
|
+
* `currententrychange` is what makes it a complete one on the browsers that
|
|
531
|
+
* have it rather than a second reading of a value the router also holds.
|
|
461
532
|
*/
|
|
462
533
|
export hook useHash(): [
|
|
463
534
|
string,
|
package/index.js
CHANGED
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.14",
|
|
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",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"*.js"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@uniflowed/core": "0.0.0-alpha.
|
|
31
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
30
|
+
"@uniflowed/core": "0.0.0-alpha.14",
|
|
31
|
+
"@uniflowed/react": "0.0.0-alpha.14"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": ">=19"
|
package/render.js
CHANGED
|
@@ -32,18 +32,54 @@
|
|
|
32
32
|
//
|
|
33
33
|
// # How the value crosses the network
|
|
34
34
|
//
|
|
35
|
-
// `RenderProvider` writes what it decided into the markup, as
|
|
36
|
-
//
|
|
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.
|
|
35
|
+
// `RenderProvider` writes what it decided into the markup, as a `<meta>`, and
|
|
36
|
+
// reads it back on the client before the first render.
|
|
43
37
|
//
|
|
44
|
-
// A
|
|
38
|
+
// A `<meta>` rather than the `<script type="application/json">` this used to
|
|
39
|
+
// be, and the reason is where the two end up. React hoists a `<title>`, a
|
|
40
|
+
// `<meta>` and a `<link>` and does not hoist a script: in a document React
|
|
41
|
+
// rendered, the meta goes into `<head>`; in a tree that is not a document — a
|
|
42
|
+
// `@uniflowed/router` application whose root layout renders content rather
|
|
43
|
+
// than `<html>` — React writes it at the front, which is the run uf's shell
|
|
44
|
+
// lifts into the head it wrote itself. A script had neither behaviour, so a
|
|
45
|
+
// provider rendered above a root layout that owns `<html>` emitted it *before*
|
|
46
|
+
// the document, and the router could not provide one without deciding where in
|
|
47
|
+
// the tree the application's `<html>` was. That is ubugeeei-prod/uf#559: a
|
|
48
|
+
// guarantee that depends on the application remembering to opt in is not one,
|
|
49
|
+
// and the carrier was the thing standing in the way of it being automatic.
|
|
50
|
+
//
|
|
51
|
+
// It also removes an escaping problem rather than solving one. A `<script>`
|
|
52
|
+
// body is raw text to the HTML parser, so the encoder had to remove `<` and
|
|
53
|
+
// the two line separators itself and the element needed
|
|
54
|
+
// `dangerouslySetInnerHTML`; an attribute value is escaped by React and decoded
|
|
55
|
+
// by the parser, so what `JSON.parse` gets back is what `JSON.stringify`
|
|
56
|
+
// produced, with nothing in between to get wrong.
|
|
57
|
+
//
|
|
58
|
+
// A page nobody prerendered has no meta to read, decides both values from the
|
|
45
59
|
// host, and is correct for the reason that there is nothing to disagree with.
|
|
46
60
|
//
|
|
61
|
+
// # Nesting replaces; it does not add
|
|
62
|
+
//
|
|
63
|
+
// The router renders one of these above every application, so an application
|
|
64
|
+
// that renders its own is nested inside that one. A nested provider inherits
|
|
65
|
+
// the envelope above it and overrides only the fields it was given, and it
|
|
66
|
+
// writes no second carrier — which is what makes "a project that wants a
|
|
67
|
+
// different clock or seed *replaces* it" true rather than aspirational. Two
|
|
68
|
+
// carriers would be two answers to one question, and the client reads the
|
|
69
|
+
// first.
|
|
70
|
+
//
|
|
71
|
+
// # What it costs, which is worth saying out loud
|
|
72
|
+
//
|
|
73
|
+
// Every uf document now carries an envelope, so two renders of one route are no
|
|
74
|
+
// longer the same bytes: the instant moved and the seed is a fresh one. That is
|
|
75
|
+
// the same price an application that followed the old advice and wrapped its own
|
|
76
|
+
// tree already paid — what changed is that every application pays it — and it is
|
|
77
|
+
// the price of the guarantee rather than an oversight. A render that has to be
|
|
78
|
+
// reproducible fixes `at` and `seed` itself, which is what those two props are
|
|
79
|
+
// for; `gives the same document however the host takes it` in
|
|
80
|
+
// `tests/library/streaming.test.js` is a test that compares two renders and says
|
|
81
|
+
// so.
|
|
82
|
+
//
|
|
47
83
|
// # What belongs in this module
|
|
48
84
|
//
|
|
49
85
|
// A value that a render has to fix rather than derive. Two so far, and they are
|
|
@@ -56,6 +92,30 @@
|
|
|
56
92
|
// — this module is about the one instant that must not move.
|
|
57
93
|
|
|
58
94
|
import * as React from "@uniflowed/react";
|
|
95
|
+
// The values by name and the namespace beside it for the types, which is what
|
|
96
|
+
// every other module in this package already does. Not a style choice, and the
|
|
97
|
+
// reason is worth the paragraph.
|
|
98
|
+
//
|
|
99
|
+
// `@uniflowed/react` is `export * from "react"` over a CommonJS package, so its
|
|
100
|
+
// namespace is filled in by the bundle's own initializer at run time rather
|
|
101
|
+
// than being known while the bundle is built. Reaching through the namespace —
|
|
102
|
+
// `React.createContext(…)` — was a reference the bundler could not attribute to
|
|
103
|
+
// an export, so the module's initializer was emitted *without* the call that
|
|
104
|
+
// fills the namespace in, and the first line of this module ran against an
|
|
105
|
+
// empty object:
|
|
106
|
+
//
|
|
107
|
+
// var init_render = __esmMin(() => {
|
|
108
|
+
// init_clock();
|
|
109
|
+
// init_random();
|
|
110
|
+
// RenderContext = react_exports.createContext(null); // TypeError
|
|
111
|
+
// });
|
|
112
|
+
//
|
|
113
|
+
// A named import is a reference the bundler has to resolve while it is
|
|
114
|
+
// bundling, and the initializer comes back. It went unnoticed until the router
|
|
115
|
+
// began rendering a `RenderProvider`, because this module reached the one build
|
|
116
|
+
// where it matters — the single file `uf build --compile` links with Bun —
|
|
117
|
+
// only then, and it failed at startup rather than at a call site.
|
|
118
|
+
import { createContext, useContext, useState } from "@uniflowed/react";
|
|
59
119
|
import { currentClock } from "@uniflowed/core/clock";
|
|
60
120
|
import type { Random } from "@uniflowed/core/random";
|
|
61
121
|
import { hostSeed, seededRandom, shuffled } from "@uniflowed/core/random";
|
|
@@ -72,10 +132,25 @@ export type RenderEnvelope = {
|
|
|
72
132
|
readonly seed: string,
|
|
73
133
|
};
|
|
74
134
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The `<meta>` name the envelope is written under, and read back out of.
|
|
137
|
+
*
|
|
138
|
+
* A name rather than an id because that is what a `<meta>` is addressed by:
|
|
139
|
+
* `document.querySelector('meta[name=…]')` is the read, and React's own
|
|
140
|
+
* hoisting treats the `name`/`content` pair as the element's identity.
|
|
141
|
+
*/
|
|
142
|
+
export const RENDER_META: string = "uf:render";
|
|
77
143
|
|
|
78
|
-
|
|
144
|
+
/**
|
|
145
|
+
* What the render above this one was anchored to, if anything was.
|
|
146
|
+
*
|
|
147
|
+
* Null outside a provider, which is the same question two callers ask of it:
|
|
148
|
+
* [`useRenderEnvelope`] asks whether the values were fixed at all, and
|
|
149
|
+
* [`RenderProvider`] asks whether it is the outermost one and therefore the
|
|
150
|
+
* one that writes the carrier. "Is there one above me" is exactly the question
|
|
151
|
+
* a context answers, and during a render there is no other way to ask it.
|
|
152
|
+
*/
|
|
153
|
+
const RenderContext: React.Context<RenderEnvelope | null> = createContext(null);
|
|
79
154
|
|
|
80
155
|
/**
|
|
81
156
|
* The envelope the server left in the document, if there is one.
|
|
@@ -83,18 +158,22 @@ const RenderContext: React.Context<RenderEnvelope | null> = React.createContext(
|
|
|
83
158
|
* Read from the DOM rather than from a global an inline script assigned,
|
|
84
159
|
* because an inline script that runs is a script a content-security policy has
|
|
85
160
|
* to allow, and this value is worth no relaxation of one.
|
|
161
|
+
*
|
|
162
|
+
* `querySelector` takes the first match rather than requiring the only one: a
|
|
163
|
+
* document that somehow carried two would still have an answer, and the first
|
|
164
|
+
* is the one every other reader of a duplicated `<meta>` takes.
|
|
86
165
|
*/
|
|
87
166
|
function embedded(): RenderEnvelope | null {
|
|
88
167
|
const document = globalThis.document;
|
|
89
168
|
if (document == null) {
|
|
90
169
|
return null;
|
|
91
170
|
}
|
|
92
|
-
const element = document.
|
|
171
|
+
const element = document.querySelector(`meta[name="${RENDER_META}"]`);
|
|
93
172
|
if (element == null) {
|
|
94
173
|
return null;
|
|
95
174
|
}
|
|
96
175
|
try {
|
|
97
|
-
const found = JSON.parse(element.
|
|
176
|
+
const found = JSON.parse(element.getAttribute("content") ?? "null");
|
|
98
177
|
if (found == null || typeof found.at !== "number" || typeof found.seed !== "string") {
|
|
99
178
|
return null;
|
|
100
179
|
}
|
|
@@ -109,9 +188,20 @@ function embedded(): RenderEnvelope | null {
|
|
|
109
188
|
}
|
|
110
189
|
}
|
|
111
190
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Decide what this render is anchored to.
|
|
193
|
+
*
|
|
194
|
+
* What the caller gave, then what a provider above already fixed, then what
|
|
195
|
+
* the markup carries, then the host — in that order, per field. `inherited`
|
|
196
|
+
* comes before `embedded()` because a provider that overrode `at` for its
|
|
197
|
+
* subtree must not have `timeZone` read back out of the document and quietly
|
|
198
|
+
* paired with somebody else's instant.
|
|
199
|
+
*/
|
|
200
|
+
function envelope(
|
|
201
|
+
given: { at?: number, timeZone?: string, seed?: string },
|
|
202
|
+
inherited: RenderEnvelope | null,
|
|
203
|
+
): RenderEnvelope {
|
|
204
|
+
const found = inherited ?? embedded();
|
|
115
205
|
const clock = currentClock();
|
|
116
206
|
return {
|
|
117
207
|
at: given.at ?? found?.at ?? clock.now(),
|
|
@@ -120,47 +210,32 @@ function envelope(given: { at?: number, timeZone?: string, seed?: string }): Ren
|
|
|
120
210
|
};
|
|
121
211
|
}
|
|
122
212
|
|
|
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
213
|
/**
|
|
141
214
|
* Fix this render's instant, zone and seed, and hand them to the tree.
|
|
142
215
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
216
|
+
* A `@uniflowed/router` application already has one: `routerView` renders this
|
|
217
|
+
* above everything, so `useRenderedAt` and `useRandom` agree across hydration
|
|
218
|
+
* without the application saying anything. Rendering one by hand is for the
|
|
219
|
+
* cases that need different values, and it is a *replacement* rather than an
|
|
220
|
+
* addition — a nested provider inherits the envelope above it, overrides only
|
|
221
|
+
* the fields it was given, and writes no second carrier. See
|
|
222
|
+
* ubugeeei-prod/uf#559.
|
|
223
|
+
*
|
|
224
|
+
* Every argument is optional and the defaults are the whole point: a server
|
|
225
|
+
* decides, the markup carries what it decided, and the browser reads it back
|
|
226
|
+
* before its first render, so neither side has to be told which one it is.
|
|
147
227
|
*
|
|
148
228
|
* `at` and `seed` are there for the two cases that are not that. A test passes
|
|
149
229
|
* them to get a page that renders the same bytes every time; an application
|
|
150
230
|
* whose instant comes from somewhere better — a request header, a loader —
|
|
151
|
-
* passes that instead.
|
|
231
|
+
* passes that instead. Both have to be values the *browser* arrives at too:
|
|
232
|
+
* they are not carried, because what is carried is the outermost envelope, and
|
|
233
|
+
* a value only one side can compute is the mismatch this module exists to
|
|
234
|
+
* remove.
|
|
152
235
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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.
|
|
236
|
+
* The carrier is a `<meta>`, which is what lets this be rendered above a root
|
|
237
|
+
* layout that owns `<html>`: React hoists it into the head of the document
|
|
238
|
+
* either way. The header of this file has the whole argument.
|
|
164
239
|
*/
|
|
165
240
|
export component RenderProvider(
|
|
166
241
|
at?: number,
|
|
@@ -168,19 +243,22 @@ export component RenderProvider(
|
|
|
168
243
|
seed?: string,
|
|
169
244
|
children: React.Node,
|
|
170
245
|
) {
|
|
246
|
+
const enclosing = useContext(RenderContext);
|
|
171
247
|
// The initializer runs once per mount, on both sides, which is what makes
|
|
172
248
|
// this a fixed value rather than a clock: a re-render for any other reason
|
|
173
249
|
// must not move the instant the page has already been drawn with.
|
|
174
|
-
const [
|
|
250
|
+
const [decided] = useState(() => envelope({ at, timeZone, seed }, enclosing));
|
|
251
|
+
// The outermost provider writes the carrier and a nested one does not, so a
|
|
252
|
+
// document holds one envelope however many providers a tree has. Not state,
|
|
253
|
+
// because whether there is a provider above this one is a fact about the
|
|
254
|
+
// shape of the tree: a subtree cannot gain or lose an enclosing provider
|
|
255
|
+
// without being remounted, so this cannot change under a re-render and ask
|
|
256
|
+
// React to add or remove an element the server's markup already settled.
|
|
257
|
+
const outermost = enclosing == null;
|
|
175
258
|
|
|
176
259
|
return (
|
|
177
|
-
<RenderContext.Provider value={
|
|
178
|
-
<
|
|
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
|
-
/>
|
|
260
|
+
<RenderContext.Provider value={decided}>
|
|
261
|
+
{outermost ? <meta name={RENDER_META} content={JSON.stringify(decided)} /> : null}
|
|
184
262
|
{children}
|
|
185
263
|
</RenderContext.Provider>
|
|
186
264
|
);
|
|
@@ -194,7 +272,7 @@ export component RenderProvider(
|
|
|
194
272
|
* clock, which is the behaviour they have always had.
|
|
195
273
|
*/
|
|
196
274
|
export hook useRenderEnvelope(): RenderEnvelope | null {
|
|
197
|
-
return
|
|
275
|
+
return useContext(RenderContext);
|
|
198
276
|
}
|
|
199
277
|
|
|
200
278
|
/**
|
|
@@ -212,10 +290,11 @@ export hook useRenderEnvelope(): RenderEnvelope | null {
|
|
|
212
290
|
export hook useRenderedAt(): Instant {
|
|
213
291
|
const found = useRenderEnvelope();
|
|
214
292
|
const at = found?.at;
|
|
215
|
-
// The number, not the instant,
|
|
216
|
-
// every render
|
|
293
|
+
// The number, not the instant, is what the memoization keys on: `Instant` is
|
|
294
|
+
// a new object every render, so a scope that depended on one would rebuild
|
|
295
|
+
// this on every render rather than on every change of clock.
|
|
217
296
|
const clockAt = at ?? currentClock().now();
|
|
218
|
-
return
|
|
297
|
+
return Temporal.Instant.fromEpochMilliseconds(clockAt);
|
|
219
298
|
}
|
|
220
299
|
|
|
221
300
|
/**
|
|
@@ -243,9 +322,9 @@ export hook useRenderTimeZone(): string {
|
|
|
243
322
|
* two sides.
|
|
244
323
|
*
|
|
245
324
|
* The stream is stateful, so a component that draws from it during render draws
|
|
246
|
-
* different numbers on a re-render. Draw
|
|
247
|
-
* numbers are for
|
|
248
|
-
* may render twice.
|
|
325
|
+
* different numbers on a re-render. Draw into a `const` keyed by what the
|
|
326
|
+
* numbers are for — which the React Compiler memoizes — or in an event, and
|
|
327
|
+
* never twice in the body of a component React may render twice.
|
|
249
328
|
*
|
|
250
329
|
* Outside a provider the seed is a constant rather than the host's, which looks
|
|
251
330
|
* like the wrong default and is the right one: two renders with no envelope
|
|
@@ -257,18 +336,19 @@ export hook useRenderTimeZone(): string {
|
|
|
257
336
|
export hook useRandom(label: string): Random {
|
|
258
337
|
const found = useRenderEnvelope();
|
|
259
338
|
const seed = found?.seed;
|
|
260
|
-
return
|
|
339
|
+
return seededRandom(seed ?? "uf").fork(label);
|
|
261
340
|
}
|
|
262
341
|
|
|
263
342
|
/**
|
|
264
343
|
* `items`, shuffled the same way on both sides of a hydration.
|
|
265
344
|
*
|
|
266
|
-
* The shuffle is
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
345
|
+
* The shuffle is memoized over the seed, the label and the items — by the React
|
|
346
|
+
* Compiler, which is where uf's memoization comes from — so it is one shuffle
|
|
347
|
+
* rather than one per render. That matters for more than speed: a fresh draw on
|
|
348
|
+
* every render would reorder the list under the reader every time anything else
|
|
349
|
+
* on the page changed.
|
|
270
350
|
*/
|
|
271
351
|
export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> {
|
|
272
352
|
const random = useRandom(label);
|
|
273
|
-
return
|
|
353
|
+
return shuffled(items, random);
|
|
274
354
|
}
|