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