@uniflowed/router 0.0.0-alpha.12 → 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.
@@ -0,0 +1,892 @@
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
+ /**
180
+ * What the server sent, taken out of the document before React touches it.
181
+ *
182
+ * `null` when there is nothing to take or too much of it, and the report says
183
+ * which. Callers hold the string for the life of the page, so this is the one
184
+ * place the size is bounded.
185
+ *
186
+ * A `Document` container — the shape an application whose root layout renders
187
+ * `<html>` hydrates into — has no `innerHTML`, so the whole element is taken
188
+ * instead. The two cases are put back together by `parseServerMarkup`, which is
189
+ * why they are allowed to differ here.
190
+ */
191
+ export function captureServerMarkup(container: Node): string | null {
192
+ const markup = serialize(container);
193
+ if (markup == null || markup.length > SERVER_MARKUP_LIMIT) {
194
+ return null;
195
+ }
196
+ return markup;
197
+ }
198
+
199
+ function serialize(container: Node): string | null {
200
+ const asDocument = container as $FlowFixMe as { documentElement?: ?Element, ... };
201
+ if (asDocument.documentElement != null) {
202
+ return asDocument.documentElement.outerHTML;
203
+ }
204
+ const asElement = container as $FlowFixMe as { innerHTML?: ?string, ... };
205
+ return typeof asElement.innerHTML === "string" ? asElement.innerHTML : null;
206
+ }
207
+
208
+ /**
209
+ * Read a snapshot back into a tree, beside the live one it will be compared to.
210
+ *
211
+ * Returns the two lists of roots, in step. A document's roots are its one
212
+ * `<html>`; an element's are its children, because `innerHTML` is what was
213
+ * kept. The doctype is not compared: React never renders one and no mismatch
214
+ * has ever been about it.
215
+ */
216
+ function parseServerMarkup(
217
+ markup: string,
218
+ container: Node,
219
+ document: Document,
220
+ ): {| server: $ReadOnlyArray<Node>, client: $ReadOnlyArray<Node> |} | null {
221
+ const asDocument = container as $FlowFixMe as { documentElement?: ?Element, ... };
222
+ const live = asDocument.documentElement;
223
+ if (live != null) {
224
+ const parsed = new DOMParser().parseFromString(markup, "text/html");
225
+ const root = parsed.documentElement;
226
+ return root == null ? null : { server: [root], client: [live] };
227
+ }
228
+ const template = document.createElement("template");
229
+ template.innerHTML = markup;
230
+ return { server: children(template.content), client: children(container) };
231
+ }
232
+
233
+ function children(node: Node): Array<Node> {
234
+ const out = [];
235
+ for (const child of node.childNodes) {
236
+ out.push(child);
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Whether a message React handed to `onRecoverableError` is about hydration.
243
+ *
244
+ * React sends more than mismatches through that callback — a Suspense boundary
245
+ * that recovered by rendering on the client is the other common one — and an
246
+ * overlay that opened for those would be an overlay people turn off. Matching
247
+ * on the message rather than on an error class because React does not export
248
+ * one, and because the wording is the part that has stayed stable across
249
+ * versions when the internals have not.
250
+ */
251
+ export function isHydrationMessage(message: string): boolean {
252
+ return (
253
+ message.includes("Hydration failed") ||
254
+ message.includes("did not match") ||
255
+ message.includes("didn't match") ||
256
+ message.includes("cannot be a descendant of") ||
257
+ message.includes("There was an error while hydrating")
258
+ );
259
+ }
260
+
261
+ /** One frame of the comparison, on an explicit stack rather than the call stack. */
262
+ type Frame =
263
+ | {| kind: "pair", server: Node, client: Node, path: string, depth: number |}
264
+ | {| kind: "unmatched", server: Node | null, client: Node | null, path: string |};
265
+
266
+ /**
267
+ * Walk both trees in document order and stop at the first disagreement.
268
+ *
269
+ * The stack is explicit and both the node count and the depth are bounded,
270
+ * because the trees are the application's and a diagnostic is not a place to
271
+ * find out what a deeply nested page does to the stack. Running out of either
272
+ * reports no difference rather than a wrong one.
273
+ */
274
+ function firstDifference(
275
+ serverRoots: $ReadOnlyArray<Node>,
276
+ clientRoots: $ReadOnlyArray<Node>,
277
+ ): HydrationDifference | null {
278
+ const stack: Array<Frame> = [];
279
+ pushChildren(stack, serverRoots, clientRoots, "", 0);
280
+
281
+ let visited = 0;
282
+ while (stack.length > 0) {
283
+ visited += 1;
284
+ if (visited > MAX_NODES) {
285
+ return null;
286
+ }
287
+ const frame = stack.pop();
288
+ if (frame == null) {
289
+ return null;
290
+ }
291
+ if (frame.kind === "unmatched") {
292
+ return {
293
+ kind: frame.server == null ? "extra" : "missing",
294
+ path: frame.path,
295
+ attribute: null,
296
+ server: frame.server == null ? null : show(frame.server),
297
+ client: frame.client == null ? null : show(frame.client),
298
+ };
299
+ }
300
+
301
+ const { server, client, path, depth } = frame;
302
+ if (server.nodeType !== client.nodeType) {
303
+ return {
304
+ kind: "node-type",
305
+ path,
306
+ attribute: null,
307
+ server: show(server),
308
+ client: show(client),
309
+ };
310
+ }
311
+ if (server.nodeType === TEXT_NODE || server.nodeType === COMMENT_NODE) {
312
+ if (server.textContent !== client.textContent) {
313
+ return {
314
+ kind: "text",
315
+ path,
316
+ attribute: null,
317
+ server: show(server),
318
+ client: show(client),
319
+ };
320
+ }
321
+ continue;
322
+ }
323
+ if (server.nodeType !== ELEMENT_NODE) {
324
+ continue;
325
+ }
326
+
327
+ const serverElement = server as $FlowFixMe as Element;
328
+ const clientElement = client as $FlowFixMe as Element;
329
+ if (serverElement.tagName !== clientElement.tagName) {
330
+ return { kind: "tag", path, attribute: null, server: show(server), client: show(client) };
331
+ }
332
+ const attribute = differingAttribute(serverElement, clientElement);
333
+ if (attribute != null) {
334
+ return {
335
+ kind: "attribute",
336
+ path,
337
+ attribute,
338
+ server: attributeOf(serverElement, attribute),
339
+ client: attributeOf(clientElement, attribute),
340
+ };
341
+ }
342
+ if (depth < MAX_DEPTH) {
343
+ pushChildren(stack, children(server), children(client), path, depth + 1);
344
+ }
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /**
350
+ * Queue the children of a matched pair, in reverse so the stack pops them in
351
+ * document order, with a marker where one side runs out before the other.
352
+ */
353
+ function pushChildren(
354
+ stack: Array<Frame>,
355
+ server: $ReadOnlyArray<Node>,
356
+ client: $ReadOnlyArray<Node>,
357
+ path: string,
358
+ depth: number,
359
+ ): void {
360
+ const most = Math.max(server.length, client.length);
361
+ for (let index = most - 1; index >= 0; index -= 1) {
362
+ const onServer = server[index] ?? null;
363
+ const onClient = client[index] ?? null;
364
+ if (onServer == null || onClient == null) {
365
+ const present = onServer ?? onClient;
366
+ stack.push({
367
+ kind: "unmatched",
368
+ server: onServer,
369
+ client: onClient,
370
+ path: present == null ? path : join(path, present, index),
371
+ });
372
+ continue;
373
+ }
374
+ stack.push({
375
+ kind: "pair",
376
+ server: onServer,
377
+ client: onClient,
378
+ path: join(path, onServer, index),
379
+ depth,
380
+ });
381
+ }
382
+ }
383
+
384
+ /** One more step of the selector that names where the difference is. */
385
+ function join(path: string, node: Node, index: number): string {
386
+ const step = describe(node, index);
387
+ return path === "" ? step : `${path} > ${step}`;
388
+ }
389
+
390
+ function describe(node: Node, index: number): string {
391
+ if (node.nodeType === TEXT_NODE) {
392
+ return `text()[${String(index)}]`;
393
+ }
394
+ if (node.nodeType === COMMENT_NODE) {
395
+ return `comment()[${String(index)}]`;
396
+ }
397
+ if (node.nodeType !== ELEMENT_NODE) {
398
+ return `node()[${String(index)}]`;
399
+ }
400
+ const element = node as $FlowFixMe as Element;
401
+ const tag = element.tagName.toLowerCase();
402
+ const id = element.getAttribute("id");
403
+ // An id is what a reader recognises, so it wins over a position. Without one
404
+ // the position is what makes the selector select one node.
405
+ return id == null || id === "" ? `${tag}:nth-child(${String(index + 1)})` : `${tag}#${id}`;
406
+ }
407
+
408
+ /** The name of the first attribute the two elements disagree about. */
409
+ function differingAttribute(server: Element, client: Element): string | null {
410
+ const names = new Set<string>();
411
+ for (const attribute of server.attributes) {
412
+ names.add(attribute.name);
413
+ }
414
+ for (const attribute of client.attributes) {
415
+ names.add(attribute.name);
416
+ }
417
+ // Sorted, so the answer does not depend on the order a parser happened to
418
+ // record them in — two runs of the same page have to blame the same
419
+ // attribute.
420
+ for (const name of [...names].sort()) {
421
+ if (attributeOf(server, name) !== attributeOf(client, name)) {
422
+ return name;
423
+ }
424
+ }
425
+ return null;
426
+ }
427
+
428
+ /**
429
+ * One attribute, as `null` when it is absent.
430
+ *
431
+ * `getAttribute` is declared as returning `string | void`, and an absent
432
+ * attribute has to compare equal to an absent attribute: without this, `void`
433
+ * on one side and `null` on the other would be reported as a difference between
434
+ * two elements that both simply do not have it.
435
+ */
436
+ function attributeOf(element: Element, name: string): string | null {
437
+ return element.getAttribute(name) ?? null;
438
+ }
439
+
440
+ /** One node, as much of it as is worth putting in a message. */
441
+ function show(node: Node): string {
442
+ const text =
443
+ node.nodeType === ELEMENT_NODE
444
+ ? (node as $FlowFixMe as Element).outerHTML
445
+ : (node.textContent ?? "");
446
+ return text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
447
+ }
448
+
449
+ /**
450
+ * Whether two pieces of text are the same sentence with different numbers.
451
+ *
452
+ * The signature of a clock, a countdown, a random number and a date formatted
453
+ * in somebody's locale: "3 minutes ago" against "5 minutes ago", "1/2/2026"
454
+ * against "02/01/2026", "0.8102…" against "0.4471…". Every run of digits on
455
+ * both sides is collapsed to one placeholder and what is left has to match
456
+ * exactly, so "42 items" against "43 items" is variable input and "Sign in"
457
+ * against "Sign out" is not.
458
+ *
459
+ * A single left-to-right scan with no regular expression, because both strings
460
+ * came out of the application's own rendered page. See `docs/security.md`.
461
+ */
462
+ function sameShape(left: string, right: string): boolean {
463
+ return digitShape(left) === digitShape(right);
464
+ }
465
+
466
+ function digitShape(text: string): string {
467
+ let out = "";
468
+ let inDigits = false;
469
+ for (let at = 0; at < text.length; at += 1) {
470
+ const code = text.charCodeAt(at);
471
+ const isDigit = code >= 48 && code <= 57;
472
+ if (isDigit) {
473
+ if (!inDigits) {
474
+ out += "#";
475
+ inDigits = true;
476
+ }
477
+ continue;
478
+ }
479
+ inDigits = false;
480
+ out += text[at];
481
+ }
482
+ return out;
483
+ }
484
+
485
+ /** Decide which of the three usual causes this looks like. */
486
+ function classify(message: string, difference: HydrationDifference | null): HydrationCause {
487
+ // React raises its own error for nesting the parser cannot honour, and its
488
+ // words are a better signal than anything the resulting tree looks like:
489
+ // by the time the tree exists the node has already been moved.
490
+ if (
491
+ message.includes("cannot be a descendant of") ||
492
+ message.includes("cannot contain a nested")
493
+ ) {
494
+ return "invalid-nesting";
495
+ }
496
+ if (difference == null) {
497
+ return "unknown";
498
+ }
499
+ // An attribute present on one side and absent on the other is a different
500
+ // question from one whose value differs, and `?? ""` below erases it: the
501
+ // empty strings exist for `sameShape`, which has nothing to compare when a
502
+ // side rendered nothing at all.
503
+ const oneSided = difference.server == null || difference.client == null;
504
+ const server = difference.server ?? "";
505
+ const client = difference.client ?? "";
506
+ // Every kind is named, so a kind added to `DifferenceKind` stops this file
507
+ // compiling rather than arriving in somebody's overlay as "unknown".
508
+ return match (difference.kind) {
509
+ "extra" | "missing" => "browser-only",
510
+ // Text on one side and whitespace on the other is a node that only one
511
+ // render produced, not two renders that disagreed about a value.
512
+ "text" if (server.trim() === "" || client.trim() === "") => "browser-only",
513
+ "text" => sameShape(server, client) ? "variable-input" : "unknown",
514
+ "attribute" if (oneSided) => "browser-only",
515
+ "attribute" => sameShape(server, client) ? "variable-input" : "unknown",
516
+ // The two trees hold different nodes here, which says nothing about why.
517
+ "node-type" | "tag" => "unknown",
518
+ };
519
+ }
520
+
521
+ const EXPLANATIONS: { readonly [HydrationCause]: string } = {
522
+ "variable-input":
523
+ "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.",
524
+ "browser-only":
525
+ "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.",
526
+ "invalid-nesting":
527
+ "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.",
528
+ unknown: "",
529
+ };
530
+
531
+ const REMEDIES: { readonly [HydrationCause]: string } = {
532
+ "variable-input":
533
+ "Decide the value once, above the tree, and pass it down, so both renders read the same number instead of each working one out.",
534
+ "browser-only":
535
+ "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.",
536
+ "invalid-nesting":
537
+ "Change the markup so the tree is one the parser can keep: the element named above cannot hold the element inside it.",
538
+ unknown: "",
539
+ };
540
+
541
+ /**
542
+ * Everything worth saying about one failed hydration.
543
+ *
544
+ * Pure, and takes the document it should work in, so the whole of the analysis
545
+ * is testable without a hydration ever having happened. Nothing here reads a
546
+ * global.
547
+ */
548
+ export function hydrationReport(input: {|
549
+ readonly message: string,
550
+ readonly serverMarkup: string | null,
551
+ readonly container: Node | null,
552
+ readonly document: Document,
553
+ readonly componentStack: string | null,
554
+ |}): HydrationReport {
555
+ const components = componentsOf(input.componentStack);
556
+ const { difference, note } = compare(input);
557
+ const cause = classify(input.message, difference);
558
+ return {
559
+ message: input.message,
560
+ cause,
561
+ explanation: EXPLANATIONS[cause],
562
+ remedy: REMEDIES[cause],
563
+ difference,
564
+ components,
565
+ note,
566
+ };
567
+ }
568
+
569
+ function compare(input: {|
570
+ readonly message: string,
571
+ readonly serverMarkup: string | null,
572
+ readonly container: Node | null,
573
+ readonly document: Document,
574
+ readonly componentStack: string | null,
575
+ |}): {| difference: HydrationDifference | null, note: string | null |} {
576
+ if (input.serverMarkup == null || input.container == null) {
577
+ return {
578
+ difference: null,
579
+ 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.`,
580
+ };
581
+ }
582
+ let trees = null;
583
+ try {
584
+ trees = parseServerMarkup(input.serverMarkup, input.container, input.document);
585
+ } catch {
586
+ // A snapshot that will not parse back is a broken snapshot, not a broken
587
+ // page: the report is worth less and the page is not worth failing for it.
588
+ trees = null;
589
+ }
590
+ if (trees == null) {
591
+ return { difference: null, note: "The copy of the server's markup could not be read back." };
592
+ }
593
+ const difference = firstDifference(trees.server, trees.client);
594
+ if (difference != null) {
595
+ return { difference, note: null };
596
+ }
597
+ return {
598
+ difference: null,
599
+ // React repairs the tree as it goes, so a mismatch reported late enough can
600
+ // have been patched before this runs. Saying so is better than showing two
601
+ // identical trees and letting the reader wonder what they missed.
602
+ note: "The two trees agree by the time they were compared, which means React had already repaired the node it reported.",
603
+ };
604
+ }
605
+
606
+ /**
607
+ * The component names out of React's stack, innermost first.
608
+ *
609
+ * Each frame is ` at Name (url:line:column)`, and the name is all of it that
610
+ * is worth showing: the url is a bundler's, and the reader is being asked
611
+ * "which of my components", not "which of my chunks".
612
+ *
613
+ * The host elements are dropped, and that is the difference between an answer
614
+ * and a list. React's stack interleaves them with the components — `p`, `main`,
615
+ * `Posted` — so the innermost frame of a text mismatch is always the `<p>` the
616
+ * text is in, which the reader can already see in the path above it. What they
617
+ * cannot see is which of their own components rendered that `<p>`. JSX's own
618
+ * rule decides which is which: a name beginning with a lower-case letter is an
619
+ * element, and everything else is a component.
620
+ */
621
+ export function componentsOf(componentStack: string | null): $ReadOnlyArray<string> {
622
+ if (componentStack == null) {
623
+ return [];
624
+ }
625
+ const names = [];
626
+ for (const line of componentStack.split("\n")) {
627
+ const trimmed = line.trim();
628
+ if (!trimmed.startsWith("at ")) {
629
+ continue;
630
+ }
631
+ const rest = trimmed.slice(3).trim();
632
+ const space = rest.indexOf(" ");
633
+ const name = space === -1 ? rest : rest.slice(0, space);
634
+ if (name !== "" && !isHostElement(name)) {
635
+ names.push(name);
636
+ }
637
+ if (names.length >= MAX_COMPONENTS) {
638
+ break;
639
+ }
640
+ }
641
+ return names;
642
+ }
643
+
644
+ /** JSX's rule: a lower-case first letter is an element, anything else a component. */
645
+ function isHostElement(name: string): boolean {
646
+ const first = name.charCodeAt(0);
647
+ return first >= 97 && first <= 122;
648
+ }
649
+
650
+ /**
651
+ * The report as text.
652
+ *
653
+ * One formatter for the overlay and the console, so the two never drift into
654
+ * saying different things about the same failure — and so a test can assert the
655
+ * words without a document.
656
+ */
657
+ export function formatHydrationReport(report: HydrationReport): string {
658
+ const lines = [];
659
+ const where = report.components.length > 0 ? ` in <${report.components[0]}>` : "";
660
+ lines.push(`Hydration mismatch${where}`);
661
+ lines.push("");
662
+ const difference = report.difference;
663
+ if (difference == null) {
664
+ lines.push(report.note ?? "There is nothing to compare.");
665
+ } else {
666
+ lines.push(`at ${difference.path}`);
667
+ if (difference.attribute != null) {
668
+ lines.push(`in the ${difference.attribute} attribute`);
669
+ }
670
+ lines.push(`server ${difference.server ?? "(nothing)"}`);
671
+ lines.push(`client ${difference.client ?? "(nothing)"}`);
672
+ }
673
+ if (report.explanation !== "") {
674
+ lines.push("");
675
+ lines.push(report.explanation);
676
+ lines.push(report.remedy);
677
+ }
678
+ if (report.components.length > 1) {
679
+ lines.push("");
680
+ lines.push(`Rendered by: ${report.components.join(" < ")}`);
681
+ }
682
+ lines.push("");
683
+ lines.push(`React said: ${headline(report.message)}`);
684
+ return lines.join("\n");
685
+ }
686
+
687
+ /**
688
+ * React's sentence, without the list of causes underneath it.
689
+ *
690
+ * The list is six bullets long and it is the thing this whole panel exists to
691
+ * replace: a reader who has just been told which node, which two values and
692
+ * which component does not need to be asked to consider whether it might have
693
+ * been a browser extension. The first line is kept because it is the string
694
+ * people paste into a search engine.
695
+ */
696
+ function headline(message: string): string {
697
+ const end = message.indexOf("\n");
698
+ return (end === -1 ? message : message.slice(0, end)).trim();
699
+ }
700
+
701
+ /** The element the overlay lives in, so a second mismatch replaces the first. */
702
+ const OVERLAY_ID = "uf-hydration-overlay";
703
+
704
+ const OVERLAY_STYLE = `
705
+ :host { all: initial; }
706
+ .panel {
707
+ position: fixed;
708
+ inset: auto 1rem 1rem 1rem;
709
+ z-index: 2147483647;
710
+ max-height: 60vh;
711
+ overflow: auto;
712
+ padding: 1rem 1.25rem;
713
+ border: 1px solid #f0b9b9;
714
+ border-radius: 6px;
715
+ background: #fff5f5;
716
+ color: #2b1b1b;
717
+ font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
718
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
719
+ }
720
+ h2 { margin: 0 0 0.5rem; font-size: 13px; font-weight: 700; }
721
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 1rem; margin: 0 0 0.75rem; }
722
+ dt { color: #8a4b4b; }
723
+ dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
724
+ p { margin: 0 0 0.5rem; font-family: ui-sans-serif, system-ui, sans-serif; }
725
+ .react { color: #6b5555; }
726
+ button {
727
+ position: absolute; top: 0.5rem; right: 0.5rem;
728
+ border: 0; background: transparent; cursor: pointer;
729
+ font: inherit; color: #8a4b4b;
730
+ }
731
+ `;
732
+
733
+ /**
734
+ * Draw the report over the page.
735
+ *
736
+ * Every value out of the page goes in with `textContent`, never as markup —
737
+ * see the header. The panel replaces itself, so a page that reports six
738
+ * mismatches shows the first one it found rather than six stacked panels, and
739
+ * the console still has all six.
740
+ */
741
+ export function showHydrationReport(report: HydrationReport, document: Document): void {
742
+ const body = document.body;
743
+ if (body == null) {
744
+ return;
745
+ }
746
+ const existing = document.getElementById(OVERLAY_ID);
747
+ if (existing != null) {
748
+ return;
749
+ }
750
+
751
+ const host = document.createElement("div");
752
+ host.id = OVERLAY_ID;
753
+ const root = host.attachShadow({ mode: "open" });
754
+
755
+ const style = document.createElement("style");
756
+ style.textContent = OVERLAY_STYLE;
757
+ root.appendChild(style);
758
+
759
+ const panel = document.createElement("div");
760
+ panel.className = "panel";
761
+
762
+ const heading = document.createElement("h2");
763
+ heading.textContent =
764
+ report.components.length > 0
765
+ ? `Hydration mismatch in <${report.components[0]}>`
766
+ : "Hydration mismatch";
767
+ panel.appendChild(heading);
768
+
769
+ const dismiss = document.createElement("button");
770
+ dismiss.type = "button";
771
+ dismiss.textContent = "close";
772
+ dismiss.addEventListener("click", () => host.remove());
773
+ panel.appendChild(dismiss);
774
+
775
+ const rows = document.createElement("dl");
776
+ const difference = report.difference;
777
+ if (difference == null) {
778
+ addRow(document, rows, "note", report.note ?? "There is nothing to compare.");
779
+ } else {
780
+ addRow(document, rows, "at", difference.path);
781
+ if (difference.attribute != null) {
782
+ addRow(document, rows, "attribute", difference.attribute);
783
+ }
784
+ addRow(document, rows, "server", difference.server ?? "(nothing)");
785
+ addRow(document, rows, "client", difference.client ?? "(nothing)");
786
+ }
787
+ if (report.components.length > 1) {
788
+ addRow(document, rows, "rendered by", report.components.join(" < "));
789
+ }
790
+ panel.appendChild(rows);
791
+
792
+ if (report.explanation !== "") {
793
+ panel.appendChild(paragraph(document, report.explanation, null));
794
+ panel.appendChild(paragraph(document, report.remedy, null));
795
+ }
796
+ panel.appendChild(paragraph(document, `React said: ${headline(report.message)}`, "react"));
797
+
798
+ root.appendChild(panel);
799
+ body.appendChild(host);
800
+ }
801
+
802
+ function addRow(document: Document, rows: Element, label: string, value: string): void {
803
+ const term = document.createElement("dt");
804
+ term.textContent = label;
805
+ const detail = document.createElement("dd");
806
+ detail.textContent = value;
807
+ rows.appendChild(term);
808
+ rows.appendChild(detail);
809
+ }
810
+
811
+ function paragraph(document: Document, text: string, className: string | null): Element {
812
+ const element = document.createElement("p");
813
+ element.textContent = text;
814
+ if (className != null) {
815
+ element.className = className;
816
+ }
817
+ return element;
818
+ }
819
+
820
+ /**
821
+ * The `onRecoverableError` `hydrate` installs in development.
822
+ *
823
+ * Everything React sends that is not a mismatch goes back to the host's own
824
+ * `reportError`, which is what React would have done: this callback replaces
825
+ * React's default rather than adding to it, so anything it swallows is
826
+ * swallowed for good.
827
+ *
828
+ * A mismatch goes to three places, and all three say the same words because all
829
+ * three come out of [`formatHydrationReport`]: the overlay, for the reader
830
+ * looking at the page; the console, for a headless run, a CI browser and a
831
+ * reader who closed the panel; and `uf dev`'s terminal, through
832
+ * [`reportDiagnostic`], for the reader who is not looking at the browser at
833
+ * all. See ubugeeei-prod/uf#583.
834
+ */
835
+ export function hydrationErrorHandler(
836
+ container: Node,
837
+ serverMarkup: string | null,
838
+ document: Document,
839
+ ): (error: mixed, info: { componentStack?: ?string, ... }) => void {
840
+ return (error, info) => {
841
+ const message = error instanceof Error ? error.message : String(error);
842
+ if (!isHydrationMessage(message)) {
843
+ reportOrThrow(error);
844
+ return;
845
+ }
846
+ let text = "";
847
+ try {
848
+ const report = hydrationReport({
849
+ message,
850
+ serverMarkup,
851
+ container,
852
+ document,
853
+ componentStack: info?.componentStack ?? null,
854
+ });
855
+ text = formatHydrationReport(report);
856
+ showHydrationReport(report, document);
857
+ } catch (failure) {
858
+ // The diagnostic failed. React's own error is the thing the reader
859
+ // actually needs, and losing it because the explanation threw would be
860
+ // the worst outcome of the whole module.
861
+ reportOrThrow(error);
862
+ reportOrThrow(failure);
863
+ return;
864
+ }
865
+ // eslint of any kind is not what stops this being noise: it is that the
866
+ // console is where a headless run, a CI browser and a reader who closed
867
+ // the panel all still see the report.
868
+ console.error(text);
869
+ // And the terminal, where every other uf diagnostic already is. The
870
+ // headline is the first line and the rest is the detail, which is the
871
+ // shape the channel carries and is why the formatter puts the sentence
872
+ // first: one report, one wording, three places. No position goes with it —
873
+ // a mismatch is a fact about a DOM node rather than about a line of a
874
+ // file, and inventing a file and a line to earn a code frame would send
875
+ // the reader somewhere that is not the answer.
876
+ const [headlineLine, ...rest] = text.split("\n");
877
+ reportDiagnostic({
878
+ severity: "error",
879
+ message: headlineLine,
880
+ detail: rest,
881
+ });
882
+ };
883
+ }
884
+
885
+ function reportOrThrow(error: mixed): void {
886
+ const host = globalThis as $FlowFixMe as { reportError?: (error: mixed) => void, ... };
887
+ if (typeof host.reportError === "function") {
888
+ host.reportError(error);
889
+ return;
890
+ }
891
+ console.error(error);
892
+ }