@uniflowed/router 0.0.0-alpha.9 → 0.2.0

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.
Files changed (51) hide show
  1. package/action.js +344 -0
  2. package/client.js +263 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +646 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/form-action.js +243 -0
  23. package/internal/head.js +219 -0
  24. package/internal/hydrate-options.js +38 -0
  25. package/internal/hydration.js +1085 -0
  26. package/internal/inspector.js +626 -0
  27. package/internal/native-links.js +67 -0
  28. package/internal/native-tree.js +89 -0
  29. package/internal/navigation-cache.js +181 -0
  30. package/internal/payload-rows.js +270 -0
  31. package/internal/payload.js +685 -0
  32. package/internal/prepare-document.js +54 -0
  33. package/internal/react-version.js +77 -0
  34. package/internal/resolve.js +1617 -0
  35. package/internal/resolved-summary.js +199 -0
  36. package/internal/routing.js +478 -0
  37. package/internal/runtime.js +1593 -1341
  38. package/internal/server-instrumentation.js +12 -0
  39. package/internal/server-route.js +58 -0
  40. package/internal/shell.js +132 -0
  41. package/internal/stream.js +766 -21
  42. package/middleware.js +274 -22
  43. package/native-navigation.js +217 -0
  44. package/native.js +416 -0
  45. package/package.json +48 -7
  46. package/routing.js +51 -0
  47. package/rsc-client.js +122 -0
  48. package/rsc-ssr.js +641 -0
  49. package/rsc.js +402 -0
  50. package/server-components.js +159 -0
  51. package/server.js +263 -106
@@ -0,0 +1,1085 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: what actually differed, when hydration
4
+ // failed.
5
+ //
6
+ // React's message is the same eleven words every time — the server rendered
7
+ // HTML did not match the client — followed by a list of the things that usually
8
+ // cause it. It is a good list. It is not an answer: the reader still has to
9
+ // find which node, in a tree of two thousand, was the one, and then work out
10
+ // which of the six suggestions applies to it.
11
+ //
12
+ // uf can do better than a list because it has both trees. `hydrate` takes a
13
+ // copy of the server's markup out of the document *before* `hydrateRoot`
14
+ // touches it, and when React reports a mismatch this module walks that copy
15
+ // against the live DOM and finds the first node where they part company. What
16
+ // the overlay then shows is not advice: it is the two values, the path to the
17
+ // node that holds them, and the component that rendered it.
18
+ //
19
+ // # Why the snapshot is taken before hydration and not after
20
+ //
21
+ // After hydration the server's markup is gone. React repairs a mismatched
22
+ // subtree by rendering the client's version over it, so by the time
23
+ // `onRecoverableError` runs, the only tree left is the one that disagreed. The
24
+ // bytes the server sent exist for exactly one moment — between the parser
25
+ // finishing and `hydrateRoot` starting — and this module's whole ability to
26
+ // answer the question depends on somebody having copied them then.
27
+ //
28
+ // It is a copy of what the *parser* made of the server's bytes rather than the
29
+ // bytes themselves, and that is the better of the two. Attribute order,
30
+ // quoting and whitespace normalisation are already applied on both sides, so a
31
+ // difference this module reports is a difference in the tree rather than in
32
+ // how two serialisers spell it — and a `<div>` the parser had to lift out of a
33
+ // `<p>` has already been lifted, which is what makes bad nesting visible as a
34
+ // difference instead of invisible as a re-parse.
35
+ //
36
+ // # Three causes, and what each one is recognised by
37
+ //
38
+ // React names six causes; three of them account for nearly everything, and each
39
+ // leaves a different shape of difference behind.
40
+ //
41
+ // `variable-input` Both sides rendered text, both are non-empty, and they
42
+ // are the same text with different numbers in it — a
43
+ // clock, a random number, a duration, a formatted date.
44
+ // `browser-only` One side rendered something and the other rendered
45
+ // nothing: the signature of a `typeof window !==
46
+ // "undefined"` branch, or of a value read out of
47
+ // `localStorage` during a render.
48
+ // `invalid-nesting` React said so. The parser moves a node the tree cannot
49
+ // hold, so the difference that reaches here is a
50
+ // structural one with no cause visible in it — but React
51
+ // raises a separate, specific error for the nesting
52
+ // itself, and that error's words are the reliable signal.
53
+ //
54
+ // Anything else is `unknown`, said plainly. A guess dressed up as a diagnosis
55
+ // costs more than no diagnosis: it sends the reader to look at the wrong line
56
+ // and makes the whole overlay less believable. The two values and the path are
57
+ // the facts, and they are what the overlay leads with; the cause is a hint
58
+ // under them.
59
+ //
60
+ // The classifier reads text with hand-written single-pass scans and
61
+ // `String.includes`, never a regular expression. React's message embeds the
62
+ // application's own content, and the project's rule for text it did not write
63
+ // is that it does not go through a backtracking engine — see `docs/security.md`.
64
+ //
65
+ // # Why the overlay is plain DOM, and inside a shadow root
66
+ //
67
+ // It is drawn without React on purpose. React has just failed to hydrate; a
68
+ // second root mounted into the same document to explain why is one more thing
69
+ // that can throw while the reader is trying to read an error message. Fifty
70
+ // lines of `createElement` do not fail.
71
+ //
72
+ // Every value that came from the page — the two pieces of markup, the path, the
73
+ // component names, React's message — is written with `textContent`. Not one is
74
+ // interpolated into HTML. An overlay that rendered the server's markup as
75
+ // markup would execute the page's own scripts a second time and, on a page
76
+ // whose mismatch is somebody's comment, would be a cross-site scripting hole in
77
+ // the tool that exists to find bugs. The shadow root is for the other
78
+ // direction: the page's stylesheet cannot reach in and hide the report.
79
+ //
80
+ // # What this deliberately does not do
81
+ //
82
+ // It does not stop the mismatch. Making most of these not happen at all is the
83
+ // job of the render envelope in `@uniflowed/hooks/render` — one instant and one
84
+ // seed decided once and read by both sides (ubugeeei-prod/uf#554) — and this is
85
+ // for the ones that still do.
86
+ //
87
+ // # It does reach the terminal
88
+ //
89
+ // It did not, once. `uf dev`'s diagnostics come up the driver's event channel
90
+ // from the Node process, and a hydration mismatch happens in the browser, which
91
+ // had no channel to send one back on — so this report existed in an overlay and
92
+ // in the console, and had to be noticed by somebody who knew to look. There is
93
+ // a channel now: `./diagnostics.js` posts to `/__uf/diagnostic`, `uf dev`
94
+ // answers it, and the same words this module formats for the overlay are
95
+ // printed by the same renderer that prints a type error. See
96
+ // ubugeeei-prod/uf#583, and #508 for where the gap was named.
97
+ //
98
+ // The overlay stays. It is in front of the reader who caused the mismatch by
99
+ // editing the page, and the terminal is for the one who did not.
100
+
101
+ import { reportDiagnostic } from "./diagnostics.js";
102
+
103
+ /** Which of the usual causes the difference looks like. */
104
+ export type HydrationCause = "variable-input" | "browser-only" | "invalid-nesting" | "unknown";
105
+
106
+ /** What kind of difference was found at the node. */
107
+ export type DifferenceKind =
108
+ /** Both sides have a node here and they are not the same kind of node. */
109
+ | "node-type"
110
+ /** Both sides rendered text, and the text is not the same. */
111
+ | "text"
112
+ /** Both sides rendered an element, and not the same element. */
113
+ | "tag"
114
+ /** Same element, and one attribute's value differs or is only on one side. */
115
+ | "attribute"
116
+ /** The client rendered a node the server did not. */
117
+ | "extra"
118
+ /** The server rendered a node the client did not. */
119
+ | "missing";
120
+
121
+ /** The first place the two trees stopped agreeing. */
122
+ export type HydrationDifference = {|
123
+ readonly kind: DifferenceKind,
124
+ /** Where in the tree, as a selector a reader can paste into the console. */
125
+ readonly path: string,
126
+ /** The attribute's name, for `kind: "attribute"`. */
127
+ readonly attribute: string | null,
128
+ /** What the server sent, or `null` where it sent nothing at all. */
129
+ readonly server: string | null,
130
+ /** What the client rendered, or `null` where it rendered nothing at all. */
131
+ readonly client: string | null,
132
+ |};
133
+
134
+ /** Everything worth telling somebody about one failed hydration. */
135
+ export type HydrationReport = {|
136
+ /** React's own message, kept because it is the thing people search for. */
137
+ readonly message: string,
138
+ readonly cause: HydrationCause,
139
+ /** One sentence saying what that cause means, in the reader's own page. */
140
+ readonly explanation: string,
141
+ /** What to do about it. Empty for `unknown`, which has no advice worth giving. */
142
+ readonly remedy: string,
143
+ /** `null` when the two trees agreed, or when there was no snapshot to compare. */
144
+ readonly difference: HydrationDifference | null,
145
+ /** The components React named, outermost last. */
146
+ readonly components: $ReadOnlyArray<string>,
147
+ /** Why there is no difference, when there is none. */
148
+ readonly note: string | null,
149
+ |};
150
+
151
+ /**
152
+ * The most server markup worth keeping.
153
+ *
154
+ * Every hydration pays this, on every page, whether or not anything ever goes
155
+ * wrong — so it is a memory cost taken out of a reader's browser for a
156
+ * diagnostic they will probably never see. Half a megabyte is a very large
157
+ * document and a very small amount of memory; a page above it gets no
158
+ * comparison and is told why, which is a better trade than a dev server that
159
+ * doubles the footprint of the largest pages.
160
+ */
161
+ export const SERVER_MARKUP_LIMIT: number = 512 * 1024;
162
+
163
+ /** The most nodes one comparison will visit. */
164
+ const MAX_NODES = 20_000;
165
+
166
+ /** The deepest a comparison will go. */
167
+ const MAX_DEPTH = 200;
168
+
169
+ /** The most of one side's markup the report will carry. */
170
+ const MAX_SHOWN = 400;
171
+
172
+ /** The most components to name out of a stack that can be hundreds deep. */
173
+ const MAX_COMPONENTS = 12;
174
+
175
+ const TEXT_NODE = 3;
176
+ const ELEMENT_NODE = 1;
177
+ const COMMENT_NODE = 8;
178
+
179
+ type DetachedHeadStyle = {|
180
+ readonly anchor: Comment,
181
+ readonly style: HTMLStyleElement,
182
+ |};
183
+
184
+ /**
185
+ * Hide development-only head artifacts before React compares the document.
186
+ *
187
+ * `uf dev` injects the React DevTools hook, the Fast Refresh preamble and
188
+ * Vite's dev styles into the head before the client entry. By the time
189
+ * `hydrate` runs the scripts have done their job; leaving them in a
190
+ * document-root app makes React compare bytes the application never rendered
191
+ * and report a hydration mismatch for uf's own instrumentation.
192
+ *
193
+ * The styles are different: the page still needs them. They are detached only
194
+ * for the instant in which React claims the document, and the returned function
195
+ * puts them back in the same order.
196
+ */
197
+ export function prepareDevHeadForHydration(document: Document): () => void {
198
+ for (const script of document.head.querySelectorAll("script")) {
199
+ if (isDevHeadScript(script)) {
200
+ script.remove();
201
+ }
202
+ }
203
+ for (const child of Array.from(document.head.childNodes)) {
204
+ if (isIgnorableHeadWhitespace(child)) {
205
+ child.remove();
206
+ }
207
+ }
208
+ const detached = [];
209
+ for (const style of document.head.querySelectorAll("style")) {
210
+ if (isViteDevStyle(style)) {
211
+ const anchor = document.createComment("uf dev style");
212
+ document.head.insertBefore(anchor, style);
213
+ style.remove();
214
+ detached.push({ anchor, style });
215
+ }
216
+ }
217
+ return () => restoreDevHeadStyles(document, detached);
218
+ }
219
+
220
+ function isDevHeadScript(script: HTMLScriptElement): boolean {
221
+ if (script.getAttribute("data-uf-dev-head-preamble") != null) {
222
+ return true;
223
+ }
224
+ const src = script.getAttribute("src");
225
+ if (src == null) {
226
+ return false;
227
+ }
228
+ const path = src.startsWith("http") ? new URL(src).pathname : src;
229
+ return path === "/@vite/client" || path === "/@id/__x00__virtual:uf/client";
230
+ }
231
+
232
+ function isViteDevStyle(style: HTMLStyleElement): boolean {
233
+ return style.getAttribute("data-vite-dev-id") != null;
234
+ }
235
+
236
+ function restoreDevHeadStyles(
237
+ document: Document,
238
+ detached: $ReadOnlyArray<DetachedHeadStyle>,
239
+ ): void {
240
+ for (const { anchor, style } of detached) {
241
+ const parent = anchor.parentNode;
242
+ if (parent != null) {
243
+ parent.insertBefore(style, anchor);
244
+ parent.removeChild(anchor);
245
+ } else {
246
+ document.head.appendChild(style);
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * What the server sent, taken out of the document before React touches it.
253
+ *
254
+ * `null` when there is nothing to take or too much of it, and the report says
255
+ * which. Callers hold the string for the life of the page, so this is the one
256
+ * place the size is bounded.
257
+ *
258
+ * A `Document` container — the shape an application whose root layout renders
259
+ * `<html>` hydrates into — has no `innerHTML`, so the whole element is taken
260
+ * instead. The two cases are put back together by `parseServerMarkup`, which is
261
+ * why they are allowed to differ here.
262
+ */
263
+ export function captureServerMarkup(container: Node): string | null {
264
+ const markup = serialize(container);
265
+ if (markup == null || markup.length > SERVER_MARKUP_LIMIT) {
266
+ return null;
267
+ }
268
+ return markup;
269
+ }
270
+
271
+ function serialize(container: Node): string | null {
272
+ const asDocument = container as $FlowFixMe as { documentElement?: ?Element, ... };
273
+ if (asDocument.documentElement != null) {
274
+ return asDocument.documentElement.outerHTML;
275
+ }
276
+ const asElement = container as $FlowFixMe as { innerHTML?: ?string, ... };
277
+ return typeof asElement.innerHTML === "string" ? asElement.innerHTML : null;
278
+ }
279
+
280
+ /**
281
+ * Read a snapshot back into a tree, beside the live one it will be compared to.
282
+ *
283
+ * Returns the two lists of roots, in step. A document's roots are its one
284
+ * `<html>`; an element's are its children, because `innerHTML` is what was
285
+ * kept. The doctype is not compared: React never renders one and no mismatch
286
+ * has ever been about it.
287
+ */
288
+ function parseServerMarkup(
289
+ markup: string,
290
+ container: Node,
291
+ document: Document,
292
+ ): {| server: $ReadOnlyArray<Node>, client: $ReadOnlyArray<Node> |} | null {
293
+ const asDocument = container as $FlowFixMe as { documentElement?: ?Element, ... };
294
+ const live = asDocument.documentElement;
295
+ if (live != null) {
296
+ const parsed = new DOMParser().parseFromString(markup, "text/html");
297
+ const root = parsed.documentElement;
298
+ return root == null ? null : { server: [root], client: [live] };
299
+ }
300
+ const template = document.createElement("template");
301
+ template.innerHTML = markup;
302
+ return { server: children(template.content), client: children(container) };
303
+ }
304
+
305
+ function children(node: Node): Array<Node> {
306
+ const out = [];
307
+ for (const child of node.childNodes) {
308
+ if (isDevHeadArtifact(child)) {
309
+ continue;
310
+ }
311
+ out.push(child);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ function isDevHeadArtifact(node: Node): boolean {
317
+ if (isIgnorableHeadWhitespace(node)) {
318
+ return true;
319
+ }
320
+ if (node.nodeType === COMMENT_NODE && node.textContent === "uf dev style") {
321
+ return true;
322
+ }
323
+ if (node.nodeType !== ELEMENT_NODE) {
324
+ return false;
325
+ }
326
+ const element = node as $FlowFixMe as Element;
327
+ if (element.tagName === "STYLE" && element.getAttribute("data-vite-dev-id") != null) {
328
+ return true;
329
+ }
330
+ if (element.tagName !== "SCRIPT") {
331
+ return false;
332
+ }
333
+ return isDevHeadScript(element as $FlowFixMe as HTMLScriptElement);
334
+ }
335
+
336
+ function isIgnorableHeadWhitespace(node: Node): boolean {
337
+ return (
338
+ node.nodeType === TEXT_NODE &&
339
+ node.parentNode != null &&
340
+ (node.parentNode as $FlowFixMe as Element).tagName === "HEAD" &&
341
+ (node.textContent ?? "").trim() === ""
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Whether a message React handed to `onRecoverableError` is about hydration.
347
+ *
348
+ * React sends more than mismatches through that callback — a Suspense boundary
349
+ * that recovered by rendering on the client is the other common one — and an
350
+ * overlay that opened for those would be an overlay people turn off. Matching
351
+ * on the message rather than on an error class because React does not export
352
+ * one, and because the wording is the part that has stayed stable across
353
+ * versions when the internals have not.
354
+ */
355
+ export function isHydrationMessage(message: string): boolean {
356
+ return (
357
+ message.includes("Hydration failed") ||
358
+ message.includes("did not match") ||
359
+ message.includes("didn't match") ||
360
+ message.includes("cannot be a descendant of") ||
361
+ message.includes("There was an error while hydrating")
362
+ );
363
+ }
364
+
365
+ /** One frame of the comparison, on an explicit stack rather than the call stack. */
366
+ type Frame =
367
+ | {| kind: "pair", server: Node, client: Node, path: string, depth: number |}
368
+ | {| kind: "unmatched", server: Node | null, client: Node | null, path: string |};
369
+
370
+ /**
371
+ * Walk both trees in document order and stop at the first disagreement.
372
+ *
373
+ * The stack is explicit and both the node count and the depth are bounded,
374
+ * because the trees are the application's and a diagnostic is not a place to
375
+ * find out what a deeply nested page does to the stack. Running out of either
376
+ * reports no difference rather than a wrong one.
377
+ */
378
+ function firstDifference(
379
+ serverRoots: $ReadOnlyArray<Node>,
380
+ clientRoots: $ReadOnlyArray<Node>,
381
+ ): HydrationDifference | null {
382
+ const stack: Array<Frame> = [];
383
+ pushChildren(stack, serverRoots, clientRoots, "", 0);
384
+
385
+ let visited = 0;
386
+ while (stack.length > 0) {
387
+ visited += 1;
388
+ if (visited > MAX_NODES) {
389
+ return null;
390
+ }
391
+ const frame = stack.pop();
392
+ if (frame == null) {
393
+ return null;
394
+ }
395
+ if (frame.kind === "unmatched") {
396
+ return {
397
+ kind: frame.server == null ? "extra" : "missing",
398
+ path: frame.path,
399
+ attribute: null,
400
+ server: frame.server == null ? null : show(frame.server),
401
+ client: frame.client == null ? null : show(frame.client),
402
+ };
403
+ }
404
+
405
+ const { server, client, path, depth } = frame;
406
+ if (server.nodeType !== client.nodeType) {
407
+ return {
408
+ kind: "node-type",
409
+ path,
410
+ attribute: null,
411
+ server: show(server),
412
+ client: show(client),
413
+ };
414
+ }
415
+ if (server.nodeType === TEXT_NODE || server.nodeType === COMMENT_NODE) {
416
+ if (server.textContent !== client.textContent) {
417
+ return {
418
+ kind: "text",
419
+ path,
420
+ attribute: null,
421
+ server: show(server),
422
+ client: show(client),
423
+ };
424
+ }
425
+ continue;
426
+ }
427
+ if (server.nodeType !== ELEMENT_NODE) {
428
+ continue;
429
+ }
430
+
431
+ const serverElement = server as $FlowFixMe as Element;
432
+ const clientElement = client as $FlowFixMe as Element;
433
+ if (serverElement.tagName !== clientElement.tagName) {
434
+ return { kind: "tag", path, attribute: null, server: show(server), client: show(client) };
435
+ }
436
+ const attribute = differingAttribute(serverElement, clientElement);
437
+ if (attribute != null) {
438
+ return {
439
+ kind: "attribute",
440
+ path,
441
+ attribute,
442
+ server: attributeOf(serverElement, attribute),
443
+ client: attributeOf(clientElement, attribute),
444
+ };
445
+ }
446
+ if (depth < MAX_DEPTH) {
447
+ pushChildren(stack, children(server), children(client), path, depth + 1);
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * Queue the children of a matched pair, in reverse so the stack pops them in
455
+ * document order, with a marker where one side runs out before the other.
456
+ */
457
+ function pushChildren(
458
+ stack: Array<Frame>,
459
+ server: $ReadOnlyArray<Node>,
460
+ client: $ReadOnlyArray<Node>,
461
+ path: string,
462
+ depth: number,
463
+ ): void {
464
+ const most = Math.max(server.length, client.length);
465
+ for (let index = most - 1; index >= 0; index -= 1) {
466
+ const onServer = server[index] ?? null;
467
+ const onClient = client[index] ?? null;
468
+ if (onServer == null || onClient == null) {
469
+ const present = onServer ?? onClient;
470
+ stack.push({
471
+ kind: "unmatched",
472
+ server: onServer,
473
+ client: onClient,
474
+ path: present == null ? path : join(path, present, index),
475
+ });
476
+ continue;
477
+ }
478
+ stack.push({
479
+ kind: "pair",
480
+ server: onServer,
481
+ client: onClient,
482
+ path: join(path, onServer, index),
483
+ depth,
484
+ });
485
+ }
486
+ }
487
+
488
+ /** One more step of the selector that names where the difference is. */
489
+ function join(path: string, node: Node, index: number): string {
490
+ const step = describe(node, index);
491
+ return path === "" ? step : `${path} > ${step}`;
492
+ }
493
+
494
+ function describe(node: Node, index: number): string {
495
+ if (node.nodeType === TEXT_NODE) {
496
+ return `text()[${String(index)}]`;
497
+ }
498
+ if (node.nodeType === COMMENT_NODE) {
499
+ return `comment()[${String(index)}]`;
500
+ }
501
+ if (node.nodeType !== ELEMENT_NODE) {
502
+ return `node()[${String(index)}]`;
503
+ }
504
+ const element = node as $FlowFixMe as Element;
505
+ const tag = element.tagName.toLowerCase();
506
+ const id = element.getAttribute("id");
507
+ // An id is what a reader recognises, so it wins over a position. Without one
508
+ // the position is what makes the selector select one node.
509
+ return id == null || id === "" ? `${tag}:nth-child(${String(index + 1)})` : `${tag}#${id}`;
510
+ }
511
+
512
+ /** The name of the first attribute the two elements disagree about. */
513
+ function differingAttribute(server: Element, client: Element): string | null {
514
+ const names = new Set<string>();
515
+ for (const attribute of server.attributes) {
516
+ names.add(attribute.name);
517
+ }
518
+ for (const attribute of client.attributes) {
519
+ names.add(attribute.name);
520
+ }
521
+ // Sorted, so the answer does not depend on the order a parser happened to
522
+ // record them in — two runs of the same page have to blame the same
523
+ // attribute.
524
+ for (const name of [...names].sort()) {
525
+ if (attributeOf(server, name) !== attributeOf(client, name)) {
526
+ return name;
527
+ }
528
+ }
529
+ return null;
530
+ }
531
+
532
+ /**
533
+ * One attribute, as `null` when it is absent.
534
+ *
535
+ * `getAttribute` is declared as returning `string | void`, and an absent
536
+ * attribute has to compare equal to an absent attribute: without this, `void`
537
+ * on one side and `null` on the other would be reported as a difference between
538
+ * two elements that both simply do not have it.
539
+ */
540
+ function attributeOf(element: Element, name: string): string | null {
541
+ return element.getAttribute(name) ?? null;
542
+ }
543
+
544
+ /** One node, as much of it as is worth putting in a message. */
545
+ function show(node: Node): string {
546
+ const text =
547
+ node.nodeType === ELEMENT_NODE
548
+ ? (node as $FlowFixMe as Element).outerHTML
549
+ : (node.textContent ?? "");
550
+ return text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
551
+ }
552
+
553
+ /**
554
+ * Whether two pieces of text are the same sentence with different numbers.
555
+ *
556
+ * The signature of a clock, a countdown, a random number and a date formatted
557
+ * in somebody's locale: "3 minutes ago" against "5 minutes ago", "1/2/2026"
558
+ * against "02/01/2026", "0.8102…" against "0.4471…". Every run of digits on
559
+ * both sides is collapsed to one placeholder and what is left has to match
560
+ * exactly, so "42 items" against "43 items" is variable input and "Sign in"
561
+ * against "Sign out" is not.
562
+ *
563
+ * A single left-to-right scan with no regular expression, because both strings
564
+ * came out of the application's own rendered page. See `docs/security.md`.
565
+ */
566
+ function sameShape(left: string, right: string): boolean {
567
+ return digitShape(left) === digitShape(right);
568
+ }
569
+
570
+ function digitShape(text: string): string {
571
+ let out = "";
572
+ let inDigits = false;
573
+ for (let at = 0; at < text.length; at += 1) {
574
+ const code = text.charCodeAt(at);
575
+ const isDigit = code >= 48 && code <= 57;
576
+ if (isDigit) {
577
+ if (!inDigits) {
578
+ out += "#";
579
+ inDigits = true;
580
+ }
581
+ continue;
582
+ }
583
+ inDigits = false;
584
+ out += text[at];
585
+ }
586
+ return out;
587
+ }
588
+
589
+ /** Decide which of the three usual causes this looks like. */
590
+ function classify(message: string, difference: HydrationDifference | null): HydrationCause {
591
+ // React raises its own error for nesting the parser cannot honour, and its
592
+ // words are a better signal than anything the resulting tree looks like:
593
+ // by the time the tree exists the node has already been moved.
594
+ if (
595
+ message.includes("cannot be a descendant of") ||
596
+ message.includes("cannot contain a nested")
597
+ ) {
598
+ return "invalid-nesting";
599
+ }
600
+ if (difference == null) {
601
+ return "unknown";
602
+ }
603
+ // An attribute present on one side and absent on the other is a different
604
+ // question from one whose value differs, and `?? ""` below erases it: the
605
+ // empty strings exist for `sameShape`, which has nothing to compare when a
606
+ // side rendered nothing at all.
607
+ const oneSided = difference.server == null || difference.client == null;
608
+ const server = difference.server ?? "";
609
+ const client = difference.client ?? "";
610
+ // Every kind is named, so a kind added to `DifferenceKind` stops this file
611
+ // compiling rather than arriving in somebody's overlay as "unknown".
612
+ return match (difference.kind) {
613
+ "extra" | "missing" => "browser-only",
614
+ // Text on one side and whitespace on the other is a node that only one
615
+ // render produced, not two renders that disagreed about a value.
616
+ "text" if (server.trim() === "" || client.trim() === "") => "browser-only",
617
+ "text" => sameShape(server, client) ? "variable-input" : "unknown",
618
+ "attribute" if (oneSided) => "browser-only",
619
+ "attribute" => sameShape(server, client) ? "variable-input" : "unknown",
620
+ // The two trees hold different nodes here, which says nothing about why.
621
+ "node-type" | "tag" => "unknown",
622
+ };
623
+ }
624
+
625
+ const EXPLANATIONS: { readonly [HydrationCause]: string } = {
626
+ "variable-input":
627
+ "The two renders computed the same text from a value that is different every time it is read — a clock, a random number, or a date formatted in a locale.",
628
+ "browser-only":
629
+ "One render produced something the other did not, which is what a value only a browser can read looks like: `window`, `localStorage`, `navigator`, or a `typeof window` branch.",
630
+ "invalid-nesting":
631
+ "The markup cannot nest the way the tree asked for, so the parser moved a node and the server's tree stopped being the shape the client rebuilt.",
632
+ unknown: "",
633
+ };
634
+
635
+ const REMEDIES: { readonly [HydrationCause]: string } = {
636
+ "variable-input":
637
+ "Decide the value once, above the tree, and pass it down, so both renders read the same number instead of each working one out.",
638
+ "browser-only":
639
+ "Read it through `useSyncExternalStore` with a server snapshot, so the first render on both sides is the same value and the browser's answer arrives in the render after it.",
640
+ "invalid-nesting":
641
+ "Change the markup so the tree is one the parser can keep: the element named above cannot hold the element inside it.",
642
+ unknown: "",
643
+ };
644
+
645
+ /**
646
+ * Everything worth saying about one failed hydration.
647
+ *
648
+ * Pure, and takes the document it should work in, so the whole of the analysis
649
+ * is testable without a hydration ever having happened. Nothing here reads a
650
+ * global.
651
+ */
652
+ export function hydrationReport(input: {|
653
+ readonly message: string,
654
+ readonly serverMarkup: string | null,
655
+ readonly container: Node | null,
656
+ readonly document: Document,
657
+ readonly componentStack: string | null,
658
+ |}): HydrationReport {
659
+ const components = componentsOf(input.componentStack);
660
+ const { difference, note } = compare(input);
661
+ const cause = classify(input.message, difference);
662
+ return {
663
+ message: input.message,
664
+ cause,
665
+ explanation: EXPLANATIONS[cause],
666
+ remedy: REMEDIES[cause],
667
+ difference,
668
+ components,
669
+ note,
670
+ };
671
+ }
672
+
673
+ function compare(input: {|
674
+ readonly message: string,
675
+ readonly serverMarkup: string | null,
676
+ readonly container: Node | null,
677
+ readonly document: Document,
678
+ readonly componentStack: string | null,
679
+ |}): {| difference: HydrationDifference | null, note: string | null |} {
680
+ if (input.serverMarkup == null || input.container == null) {
681
+ return {
682
+ difference: null,
683
+ note: `No copy of the server's markup was kept, so there is nothing to compare against. A document over ${String(SERVER_MARKUP_LIMIT)} bytes is not snapshotted.`,
684
+ };
685
+ }
686
+ let trees = null;
687
+ try {
688
+ trees = parseServerMarkup(input.serverMarkup, input.container, input.document);
689
+ } catch {
690
+ // A snapshot that will not parse back is a broken snapshot, not a broken
691
+ // page: the report is worth less and the page is not worth failing for it.
692
+ trees = null;
693
+ }
694
+ if (trees == null) {
695
+ return { difference: null, note: "The copy of the server's markup could not be read back." };
696
+ }
697
+ const difference = firstDifference(trees.server, trees.client);
698
+ if (difference != null) {
699
+ return { difference, note: null };
700
+ }
701
+ return {
702
+ difference: null,
703
+ // React repairs the tree as it goes, so a mismatch reported late enough can
704
+ // have been patched before this runs. Saying so is better than showing two
705
+ // identical trees and letting the reader wonder what they missed.
706
+ note: "The two trees agree by the time they were compared, which means React had already repaired the node it reported.",
707
+ };
708
+ }
709
+
710
+ /**
711
+ * The component names out of React's stack, innermost first.
712
+ *
713
+ * Each frame is ` at Name (url:line:column)`, and the name is all of it that
714
+ * is worth showing: the url is a bundler's, and the reader is being asked
715
+ * "which of my components", not "which of my chunks".
716
+ *
717
+ * The host elements are dropped, and that is the difference between an answer
718
+ * and a list. React's stack interleaves them with the components — `p`, `main`,
719
+ * `Posted` — so the innermost frame of a text mismatch is always the `<p>` the
720
+ * text is in, which the reader can already see in the path above it. What they
721
+ * cannot see is which of their own components rendered that `<p>`. JSX's own
722
+ * rule decides which is which: a name beginning with a lower-case letter is an
723
+ * element, and everything else is a component.
724
+ */
725
+ export function componentsOf(componentStack: string | null): $ReadOnlyArray<string> {
726
+ if (componentStack == null) {
727
+ return [];
728
+ }
729
+ const names = [];
730
+ for (const line of componentStack.split("\n")) {
731
+ const trimmed = line.trim();
732
+ if (!trimmed.startsWith("at ")) {
733
+ continue;
734
+ }
735
+ const rest = trimmed.slice(3).trim();
736
+ const space = rest.indexOf(" ");
737
+ const name = space === -1 ? rest : rest.slice(0, space);
738
+ if (name !== "" && !isHostElement(name)) {
739
+ names.push(name);
740
+ }
741
+ if (names.length >= MAX_COMPONENTS) {
742
+ break;
743
+ }
744
+ }
745
+ return names;
746
+ }
747
+
748
+ /** JSX's rule: a lower-case first letter is an element, anything else a component. */
749
+ function isHostElement(name: string): boolean {
750
+ const first = name.charCodeAt(0);
751
+ return first >= 97 && first <= 122;
752
+ }
753
+
754
+ /**
755
+ * The report as text.
756
+ *
757
+ * One formatter for the overlay and the console, so the two never drift into
758
+ * saying different things about the same failure — and so a test can assert the
759
+ * words without a document.
760
+ */
761
+ export function formatHydrationReport(report: HydrationReport): string {
762
+ const lines = [];
763
+ lines.push(reportTitle(report));
764
+ lines.push("");
765
+ lines.push(
766
+ "The server HTML and the browser's first render disagreed. React repaired the page, but the first paint may not be the UI you meant to ship.",
767
+ );
768
+ lines.push("");
769
+ const difference = report.difference;
770
+ if (difference == null) {
771
+ lines.push("What changed");
772
+ lines.push(report.note ?? "There is nothing to compare.");
773
+ } else {
774
+ lines.push("What changed");
775
+ lines.push(`path ${difference.path}`);
776
+ if (difference.attribute != null) {
777
+ lines.push(`attribute ${difference.attribute}`);
778
+ }
779
+ lines.push(`server rendered ${difference.server ?? "(nothing)"}`);
780
+ lines.push(`browser rendered ${difference.client ?? "(nothing)"}`);
781
+ }
782
+ if (report.explanation !== "") {
783
+ lines.push("");
784
+ lines.push("Likely cause");
785
+ lines.push(report.explanation);
786
+ lines.push("");
787
+ lines.push("Try this next");
788
+ lines.push(report.remedy);
789
+ }
790
+ if (report.components.length > 1) {
791
+ lines.push("");
792
+ lines.push(`Component trail: ${report.components.join(" < ")}`);
793
+ }
794
+ lines.push("");
795
+ lines.push(`React's original message: ${headline(report.message)}`);
796
+ return lines.join("\n");
797
+ }
798
+
799
+ function reportTitle(report: HydrationReport): string {
800
+ return report.components.length > 0
801
+ ? `Hydration mismatch in <${report.components[0]}>`
802
+ : "Hydration mismatch";
803
+ }
804
+
805
+ /**
806
+ * React's sentence, without the list of causes underneath it.
807
+ *
808
+ * The list is six bullets long and it is the thing this whole panel exists to
809
+ * replace: a reader who has just been told which node, which two values and
810
+ * which component does not need to be asked to consider whether it might have
811
+ * been a browser extension. The first line is kept because it is the string
812
+ * people paste into a search engine.
813
+ */
814
+ function headline(message: string): string {
815
+ const end = message.indexOf("\n");
816
+ return (end === -1 ? message : message.slice(0, end)).trim();
817
+ }
818
+
819
+ /** The element the overlay lives in, so a second mismatch replaces the first. */
820
+ const OVERLAY_ID = "uf-hydration-overlay";
821
+
822
+ const OVERLAY_STYLE = `
823
+ :host { all: initial; }
824
+ .panel {
825
+ position: fixed;
826
+ inset: auto 1rem 1rem 1rem;
827
+ z-index: 2147483647;
828
+ max-height: min(78vh, 720px);
829
+ overflow: auto;
830
+ padding: 1rem;
831
+ border: 1px solid #fecaca;
832
+ border-radius: 8px;
833
+ background: #fffafa;
834
+ color: #1f2937;
835
+ font: 14px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
836
+ box-shadow: 0 18px 60px rgba(15, 23, 42, 0.24);
837
+ }
838
+ .top { display: grid; gap: 0.35rem; padding-right: 2rem; }
839
+ .badge {
840
+ color: #be123c;
841
+ font-size: 0.75rem;
842
+ font-weight: 800;
843
+ letter-spacing: 0;
844
+ text-transform: uppercase;
845
+ }
846
+ h2 { margin: 0; color: #111827; font-size: 1.05rem; line-height: 1.25; }
847
+ h3 { margin: 0 0 0.45rem; color: #9f1239; font-size: 0.82rem; line-height: 1.25; }
848
+ .lead { margin: 0; color: #4b5563; }
849
+ section {
850
+ margin-top: 0.85rem;
851
+ padding-top: 0.85rem;
852
+ border-top: 1px solid #fee2e2;
853
+ }
854
+ dl {
855
+ display: grid;
856
+ grid-template-columns: max-content minmax(0, 1fr);
857
+ gap: 0.35rem 0.85rem;
858
+ margin: 0;
859
+ }
860
+ dt { color: #9f1239; font-weight: 800; }
861
+ dd {
862
+ margin: 0;
863
+ color: #111827;
864
+ overflow-wrap: anywhere;
865
+ white-space: pre-wrap;
866
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
867
+ font-size: 0.82rem;
868
+ }
869
+ p { margin: 0; color: #374151; }
870
+ .react { color: #6b7280; font-size: 0.82rem; }
871
+ button {
872
+ position: absolute; top: 0.65rem; right: 0.65rem;
873
+ border: 1px solid #fecaca;
874
+ border-radius: 8px;
875
+ background: #ffffff;
876
+ cursor: pointer;
877
+ font: inherit;
878
+ color: #9f1239;
879
+ padding: 0.3rem 0.55rem;
880
+ }
881
+ @media (min-width: 760px) {
882
+ .panel { left: auto; width: min(680px, calc(100vw - 2rem)); }
883
+ }
884
+ `;
885
+
886
+ /**
887
+ * Draw the report over the page.
888
+ *
889
+ * Every value out of the page goes in with `textContent`, never as markup —
890
+ * see the header. The panel replaces itself, so a page that reports six
891
+ * mismatches shows the first one it found rather than six stacked panels, and
892
+ * the console still has all six.
893
+ */
894
+ export function showHydrationReport(report: HydrationReport, document: Document): void {
895
+ const body = document.body;
896
+ if (body == null) {
897
+ return;
898
+ }
899
+ const existing = document.getElementById(OVERLAY_ID);
900
+ if (existing != null) {
901
+ return;
902
+ }
903
+
904
+ const host = document.createElement("div");
905
+ host.id = OVERLAY_ID;
906
+ const root = host.attachShadow({ mode: "open" });
907
+
908
+ const style = document.createElement("style");
909
+ style.textContent = OVERLAY_STYLE;
910
+ root.appendChild(style);
911
+
912
+ const panel = document.createElement("div");
913
+ panel.className = "panel";
914
+ panel.setAttribute("role", "dialog");
915
+ panel.setAttribute("aria-modal", "false");
916
+ panel.setAttribute("aria-labelledby", "uf-hydration-title");
917
+ panel.setAttribute("aria-describedby", "uf-hydration-lead");
918
+
919
+ const top = document.createElement("div");
920
+ top.className = "top";
921
+ const badge = document.createElement("div");
922
+ badge.className = "badge";
923
+ badge.textContent = "uf dev hydration report";
924
+ top.appendChild(badge);
925
+ const heading = document.createElement("h2");
926
+ heading.id = "uf-hydration-title";
927
+ heading.textContent = reportTitle(report);
928
+ top.appendChild(heading);
929
+ const lead = paragraph(
930
+ document,
931
+ "The server HTML and the browser's first render did not match. React repaired it, but this is the first place to look.",
932
+ "lead",
933
+ );
934
+ lead.id = "uf-hydration-lead";
935
+ top.appendChild(lead);
936
+ panel.appendChild(top);
937
+
938
+ const dismiss = document.createElement("button");
939
+ dismiss.type = "button";
940
+ dismiss.textContent = "Close";
941
+ dismiss.setAttribute("aria-label", "Close hydration report");
942
+ dismiss.addEventListener("click", () => host.remove());
943
+ panel.appendChild(dismiss);
944
+
945
+ const rows = document.createElement("dl");
946
+ const difference = report.difference;
947
+ if (difference == null) {
948
+ addRow(document, rows, "note", report.note ?? "There is nothing to compare.");
949
+ } else {
950
+ addRow(document, rows, "at", difference.path);
951
+ if (difference.attribute != null) {
952
+ addRow(document, rows, "attribute", difference.attribute);
953
+ }
954
+ addRow(document, rows, "server", difference.server ?? "(nothing)");
955
+ addRow(document, rows, "client", difference.client ?? "(nothing)");
956
+ }
957
+ panel.appendChild(section(document, "What changed", rows));
958
+
959
+ if (report.explanation !== "") {
960
+ panel.appendChild(
961
+ section(document, "Likely cause", paragraph(document, report.explanation, null)),
962
+ );
963
+ panel.appendChild(section(document, "Try this next", paragraph(document, report.remedy, null)));
964
+ }
965
+ if (report.components.length > 1) {
966
+ panel.appendChild(
967
+ section(
968
+ document,
969
+ "Component trail",
970
+ paragraph(document, report.components.join(" < "), null),
971
+ ),
972
+ );
973
+ }
974
+ panel.appendChild(
975
+ section(
976
+ document,
977
+ "React's original message",
978
+ paragraph(document, headline(report.message), "react"),
979
+ ),
980
+ );
981
+
982
+ root.appendChild(panel);
983
+ body.appendChild(host);
984
+ }
985
+
986
+ function section(document: Document, title: string, child: Element): Element {
987
+ const element = document.createElement("section");
988
+ const heading = document.createElement("h3");
989
+ heading.textContent = title;
990
+ element.appendChild(heading);
991
+ element.appendChild(child);
992
+ return element;
993
+ }
994
+
995
+ function addRow(document: Document, rows: Element, label: string, value: string): void {
996
+ const term = document.createElement("dt");
997
+ term.textContent = label;
998
+ const detail = document.createElement("dd");
999
+ detail.textContent = value;
1000
+ rows.appendChild(term);
1001
+ rows.appendChild(detail);
1002
+ }
1003
+
1004
+ function paragraph(document: Document, text: string, className: string | null): Element {
1005
+ const element = document.createElement("p");
1006
+ element.textContent = text;
1007
+ if (className != null) {
1008
+ element.className = className;
1009
+ }
1010
+ return element;
1011
+ }
1012
+
1013
+ /**
1014
+ * The `onRecoverableError` `hydrate` installs in development.
1015
+ *
1016
+ * Everything React sends that is not a mismatch goes back to the host's own
1017
+ * `reportError`, which is what React would have done: this callback replaces
1018
+ * React's default rather than adding to it, so anything it swallows is
1019
+ * swallowed for good.
1020
+ *
1021
+ * A mismatch goes to three places, and all three say the same words because all
1022
+ * three come out of [`formatHydrationReport`]: the overlay, for the reader
1023
+ * looking at the page; the console, for a headless run, a CI browser and a
1024
+ * reader who closed the panel; and `uf dev`'s terminal, through
1025
+ * [`reportDiagnostic`], for the reader who is not looking at the browser at
1026
+ * all. See ubugeeei-prod/uf#583.
1027
+ */
1028
+ export function hydrationErrorHandler(
1029
+ container: Node,
1030
+ serverMarkup: string | null,
1031
+ document: Document,
1032
+ ): (error: mixed, info: { componentStack?: ?string, ... }) => void {
1033
+ return (error, info) => {
1034
+ const message = error instanceof Error ? error.message : String(error);
1035
+ if (!isHydrationMessage(message)) {
1036
+ reportOrThrow(error);
1037
+ return;
1038
+ }
1039
+ let text = "";
1040
+ try {
1041
+ const report = hydrationReport({
1042
+ message,
1043
+ serverMarkup,
1044
+ container,
1045
+ document,
1046
+ componentStack: info?.componentStack ?? null,
1047
+ });
1048
+ text = formatHydrationReport(report);
1049
+ showHydrationReport(report, document);
1050
+ } catch (failure) {
1051
+ // The diagnostic failed. React's own error is the thing the reader
1052
+ // actually needs, and losing it because the explanation threw would be
1053
+ // the worst outcome of the whole module.
1054
+ reportOrThrow(error);
1055
+ reportOrThrow(failure);
1056
+ return;
1057
+ }
1058
+ // eslint of any kind is not what stops this being noise: it is that the
1059
+ // console is where a headless run, a CI browser and a reader who closed
1060
+ // the panel all still see the report.
1061
+ console.error(text);
1062
+ // And the terminal, where every other uf diagnostic already is. The
1063
+ // headline is the first line and the rest is the detail, which is the
1064
+ // shape the channel carries and is why the formatter puts the sentence
1065
+ // first: one report, one wording, three places. No position goes with it —
1066
+ // a mismatch is a fact about a DOM node rather than about a line of a
1067
+ // file, and inventing a file and a line to earn a code frame would send
1068
+ // the reader somewhere that is not the answer.
1069
+ const [headlineLine, ...rest] = text.split("\n");
1070
+ reportDiagnostic({
1071
+ severity: "error",
1072
+ message: headlineLine,
1073
+ detail: rest,
1074
+ });
1075
+ };
1076
+ }
1077
+
1078
+ function reportOrThrow(error: mixed): void {
1079
+ const host = globalThis as $FlowFixMe as { reportError?: (error: mixed) => void, ... };
1080
+ if (typeof host.reportError === "function") {
1081
+ host.reportError(error);
1082
+ return;
1083
+ }
1084
+ console.error(error);
1085
+ }