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

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/render.js ADDED
@@ -0,0 +1,354 @@
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 a `<meta>`, and
36
+ // reads it back on the client before the first render.
37
+ //
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
59
+ // host, and is correct for the reason that there is nothing to disagree with.
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
+ // `packages/router/streaming.test.js` is a test that compares two renders and says
81
+ // so.
82
+ //
83
+ // # What belongs in this module
84
+ //
85
+ // A value that a render has to fix rather than derive. Two so far, and they are
86
+ // the two React itself has an answer for exactly one of: `useId` solves ids by
87
+ // deriving them from the position in the tree, which works for an id and for
88
+ // nothing that has to be shuffled or counted from.
89
+ //
90
+ // Not here: a hook that reads a clock to schedule work. `useInterval`,
91
+ // `useTimeout` and `useNow` are `timing.js`'s, and they read the *current* time
92
+ // — this module is about the one instant that must not move.
93
+
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";
119
+ import { currentClock } from "@uniflowed/core/clock";
120
+ import type { Random } from "@uniflowed/core/random";
121
+ import { hostSeed, seededRandom, shuffled } from "@uniflowed/core/random";
122
+ import type { Instant } from "@uniflowed/core/temporal";
123
+ import { Temporal } from "@uniflowed/core/temporal";
124
+
125
+ /** What a render fixes, and what travels to the client. */
126
+ export type RenderEnvelope = {
127
+ /** The instant the render was anchored to, in epoch milliseconds. */
128
+ readonly at: number,
129
+ /** The IANA zone the server was in. Not the reader's — see `Time`. */
130
+ readonly timeZone: string,
131
+ /** The seed both sides replay the same numbers from. */
132
+ readonly seed: string,
133
+ };
134
+
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";
143
+
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);
154
+
155
+ /**
156
+ * The envelope the server left in the document, if there is one.
157
+ *
158
+ * Read from the DOM rather than from a global an inline script assigned,
159
+ * because an inline script that runs is a script a content-security policy has
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.
165
+ */
166
+ function embedded(): RenderEnvelope | null {
167
+ const document = globalThis.document;
168
+ if (document == null) {
169
+ return null;
170
+ }
171
+ const element = document.querySelector(`meta[name="${RENDER_META}"]`);
172
+ if (element == null) {
173
+ return null;
174
+ }
175
+ try {
176
+ const found = JSON.parse(element.getAttribute("content") ?? "null");
177
+ if (found == null || typeof found.at !== "number" || typeof found.seed !== "string") {
178
+ return null;
179
+ }
180
+ return { at: found.at, timeZone: String(found.timeZone), seed: found.seed };
181
+ } catch {
182
+ // A truncated document — a stream that was cut off — leaves half a JSON
183
+ // object here. Deciding fresh values is wrong on that page in exactly the
184
+ // way this module exists to prevent, and it is still better than a render
185
+ // that throws: the page renders, and hydration reports what it always
186
+ // would have.
187
+ return null;
188
+ }
189
+ }
190
+
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();
205
+ const clock = currentClock();
206
+ return {
207
+ at: given.at ?? found?.at ?? clock.now(),
208
+ timeZone: given.timeZone ?? found?.timeZone ?? clock.timeZone(),
209
+ seed: given.seed ?? found?.seed ?? hostSeed(),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Fix this render's instant, zone and seed, and hand them to the tree.
215
+ *
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.
227
+ *
228
+ * `at` and `seed` are there for the two cases that are not that. A test passes
229
+ * them to get a page that renders the same bytes every time; an application
230
+ * whose instant comes from somewhere better — a request header, a loader —
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.
235
+ *
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.
239
+ */
240
+ export component RenderProvider(
241
+ at?: number,
242
+ timeZone?: string,
243
+ seed?: string,
244
+ children: React.Node,
245
+ ) {
246
+ const enclosing = useContext(RenderContext);
247
+ // The initializer runs once per mount, on both sides, which is what makes
248
+ // this a fixed value rather than a clock: a re-render for any other reason
249
+ // must not move the instant the page has already been drawn with.
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;
258
+
259
+ return (
260
+ <RenderContext.Provider value={decided}>
261
+ {outermost ? <meta name={RENDER_META} content={JSON.stringify(decided)} /> : null}
262
+ {children}
263
+ </RenderContext.Provider>
264
+ );
265
+ }
266
+
267
+ /**
268
+ * What this render was anchored to, or `null` outside a `RenderProvider`.
269
+ *
270
+ * Null rather than a fabricated envelope, because "nobody fixed these values"
271
+ * is a fact the hooks in `timing.js` act on: without a provider they read the
272
+ * clock, which is the behaviour they have always had.
273
+ */
274
+ export hook useRenderEnvelope(): RenderEnvelope | null {
275
+ return useContext(RenderContext);
276
+ }
277
+
278
+ /**
279
+ * The instant this page was rendered at, as a `Temporal.Instant`.
280
+ *
281
+ * Constant for the life of the render, on both sides, which is what makes it
282
+ * safe to put in the markup. It is not "now" and does not become "now": a page
283
+ * left open for an hour still reports the instant it was rendered at, and a
284
+ * label that has to stay true while the reader looks at it is `useTimeAgo`.
285
+ *
286
+ * Falls back to the clock outside a provider, which is right for a page that is
287
+ * only ever rendered once — and is a hydration mismatch on one that is
288
+ * prerendered, which is what the provider is for.
289
+ */
290
+ export hook useRenderedAt(): Instant {
291
+ const found = useRenderEnvelope();
292
+ const at = found?.at;
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.
296
+ const clockAt = at ?? currentClock().now();
297
+ return Temporal.Instant.fromEpochMilliseconds(clockAt);
298
+ }
299
+
300
+ /**
301
+ * The zone the render was made in.
302
+ *
303
+ * The server's, not the reader's, and the distinction is the second half of the
304
+ * hydration problem rather than a detail: markup formatted in the reader's zone
305
+ * cannot match markup formatted in the server's, so a component renders this one
306
+ * and localises after hydration. `@uniflowed/web`'s `Time` is that component.
307
+ */
308
+ export hook useRenderTimeZone(): string {
309
+ const found = useRenderEnvelope();
310
+ return found?.timeZone ?? currentClock().timeZone();
311
+ }
312
+
313
+ /**
314
+ * A stream of random numbers that both renders produce identically.
315
+ *
316
+ * `label` names the stream, and naming it is what makes it independent of every
317
+ * other one: two components that ask for `"featured"` and `"sidebar"` get the
318
+ * same numbers whatever order they render in, and whatever suspends between
319
+ * them. Sharing one stream would make each component's numbers depend on how
320
+ * many the components above it happened to draw — stable in a synchronous
321
+ * render, and not stable once a boundary resolves at a different moment on the
322
+ * two sides.
323
+ *
324
+ * The stream is stateful, so a component that draws from it during render draws
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.
328
+ *
329
+ * Outside a provider the seed is a constant rather than the host's, which looks
330
+ * like the wrong default and is the right one: two renders with no envelope
331
+ * between them still have to agree, and a constant is the only seed both of them
332
+ * can arrive at. What it costs is that every such page shuffles the same way,
333
+ * which is a reason to render a provider rather than a reason to be
334
+ * unpredictable here.
335
+ */
336
+ export hook useRandom(label: string): Random {
337
+ const found = useRenderEnvelope();
338
+ const seed = found?.seed;
339
+ return seededRandom(seed ?? "uf").fork(label);
340
+ }
341
+
342
+ /**
343
+ * `items`, shuffled the same way on both sides of a hydration.
344
+ *
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.
350
+ */
351
+ export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> {
352
+ const random = useRandom(label);
353
+ return shuffled(items, random);
354
+ }