@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/async.js +99 -16
- package/browser.js +934 -60
- package/channels.js +224 -0
- package/dom.js +262 -58
- package/events.js +300 -0
- package/index.js +176 -21
- package/keyboard.js +328 -0
- package/lifecycle.js +12 -6
- package/package.json +9 -3
- package/render.js +354 -0
- package/state.js +378 -31
- package/timing.js +332 -17
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
|
+
}
|