Skip to content

Commit 118ad2a

Browse files
authored
Validate DOM nesting for hydration before the hydration warns / errors (#28434)
If there's invalid dom nesting, there will be mismatches following but the nesting is the most important cause of the problem. Previously we would include the DOM nesting when rerendering thanks to the new model of throw and recovery. However, the log would come during the recovery phase which is after we've already logged that there was a hydration mismatch. People would consistently miss this log. Which is fair because you should always look at the first log first as the most probable cause. This ensures that we log in the hydration phase if there's a dom nesting issue. This assumes that the consequence of nesting will appear such that the won't have a mismatch before this. That's typically the case because the node will move up and to be a later sibling. So as long as that happens and we keep hydrating depth first, it should hold true. There might be an issue if there's a suspense boundary between the nodes we'll find discover the new child in the outer path since suspense boundaries as breadth first. Before: <img width="996" alt="Screenshot 2024-02-23 at 7 34 01 PM" src="https://github.com/facebook/react/assets/63648/af70cf7f-898b-477f-be39-13b01cfe585f"> After: <img width="853" alt="Screenshot 2024-02-23 at 7 22 24 PM" src="https://github.com/facebook/react/assets/63648/896c6348-1620-4f99-881d-b6069263925e"> Cameo: RSC stacks.
1 parent 16d3f78 commit 118ad2a

11 files changed

+181
-59
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,19 @@ export function getFirstHydratableChildWithinSuspenseInstance(
13551355
return getNextHydratable(parentInstance.nextSibling);
13561356
}
13571357

1358+
export function validateHydratableInstance(
1359+
type: string,
1360+
props: Props,
1361+
hostContext: HostContext,
1362+
): boolean {
1363+
if (__DEV__) {
1364+
// TODO: take namespace into account when validating.
1365+
const hostContextDev: HostContextDev = (hostContext: any);
1366+
return validateDOMNesting(type, hostContextDev.ancestorInfo);
1367+
}
1368+
return true;
1369+
}
1370+
13581371
export function hydrateInstance(
13591372
instance: Instance,
13601373
type: string,
@@ -1383,6 +1396,20 @@ export function hydrateInstance(
13831396
);
13841397
}
13851398

1399+
export function validateHydratableTextInstance(
1400+
text: string,
1401+
hostContext: HostContext,
1402+
): boolean {
1403+
if (__DEV__) {
1404+
const hostContextDev = ((hostContext: any): HostContextDev);
1405+
const ancestor = hostContextDev.ancestorInfo.current;
1406+
if (ancestor != null) {
1407+
return validateTextNesting(text, ancestor.tag);
1408+
}
1409+
}
1410+
return true;
1411+
}
1412+
13861413
export function hydrateTextInstance(
13871414
textInstance: TextInstance,
13881415
text: string,

packages/react-dom-bindings/src/client/validateDOMNesting.js

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ const didWarn: {[string]: boolean} = {};
441441
function validateDOMNesting(
442442
childTag: string,
443443
ancestorInfo: AncestorInfoDev,
444-
): void {
444+
): boolean {
445445
if (__DEV__) {
446446
ancestorInfo = ancestorInfo || emptyAncestorInfoDev;
447447
const parentInfo = ancestorInfo.current;
@@ -455,7 +455,7 @@ function validateDOMNesting(
455455
: findInvalidAncestorForTag(childTag, ancestorInfo);
456456
const invalidParentOrAncestor = invalidParent || invalidAncestor;
457457
if (!invalidParentOrAncestor) {
458-
return;
458+
return true;
459459
}
460460

461461
const ancestorTag = invalidParentOrAncestor.tag;
@@ -464,7 +464,7 @@ function validateDOMNesting(
464464
// eslint-disable-next-line react-internal/safe-string-coercion
465465
String(!!invalidParent) + '|' + childTag + '|' + ancestorTag;
466466
if (didWarn[warnKey]) {
467-
return;
467+
return false;
468468
}
469469
didWarn[warnKey] = true;
470470

@@ -477,45 +477,56 @@ function validateDOMNesting(
477477
'the browser.';
478478
}
479479
console.error(
480-
'%s cannot appear as a child of <%s>.%s',
480+
'In HTML, %s cannot be a child of <%s>.%s\n' +
481+
'This will cause a hydration error.',
481482
tagDisplayName,
482483
ancestorTag,
483484
info,
484485
);
485486
} else {
486487
console.error(
487-
'%s cannot appear as a descendant of ' + '<%s>.',
488+
'In HTML, %s cannot be a descendant of <%s>.\n' +
489+
'This will cause a hydration error.',
488490
tagDisplayName,
489491
ancestorTag,
490492
);
491493
}
494+
return false;
492495
}
496+
return true;
493497
}
494498

495-
function validateTextNesting(childText: string, parentTag: string): void {
499+
function validateTextNesting(childText: string, parentTag: string): boolean {
496500
if (__DEV__) {
497501
if (isTagValidWithParent('#text', parentTag)) {
498-
return;
502+
return true;
499503
}
500504

501505
// eslint-disable-next-line react-internal/safe-string-coercion
502506
const warnKey = '#text|' + parentTag;
503507
if (didWarn[warnKey]) {
504-
return;
508+
return false;
505509
}
506510
didWarn[warnKey] = true;
507511

508512
if (/\S/.test(childText)) {
509-
console.error('Text nodes cannot appear as a child of <%s>.', parentTag);
513+
console.error(
514+
'In HTML, text nodes cannot be a child of <%s>.\n' +
515+
'This will cause a hydration error.',
516+
parentTag,
517+
);
510518
} else {
511519
console.error(
512-
'Whitespace text nodes cannot appear as a child of <%s>. ' +
520+
'In HTML, whitespace text nodes cannot be a child of <%s>. ' +
513521
"Make sure you don't have any extra whitespace between tags on " +
514-
'each line of your source code.',
522+
'each line of your source code.\n' +
523+
'This will cause a hydration error.',
515524
parentTag,
516525
);
517526
}
527+
return false;
518528
}
529+
return true;
519530
}
520531

521532
export {updatedAncestorInfoDev, validateDOMNesting, validateTextNesting};

packages/react-dom/src/__tests__/ReactDOMComponent-test.js

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,8 +2188,9 @@ describe('ReactDOMComponent', () => {
21882188
);
21892189
});
21902190
}).toErrorDev([
2191-
'Warning: <tr> cannot appear as a child of ' +
2192-
'<div>.' +
2191+
'Warning: In HTML, <tr> cannot be a child of ' +
2192+
'<div>.\n' +
2193+
'This will cause a hydration error.' +
21932194
'\n in tr (at **)' +
21942195
'\n in div (at **)',
21952196
]);
@@ -2208,8 +2209,9 @@ describe('ReactDOMComponent', () => {
22082209
);
22092210
});
22102211
}).toErrorDev(
2211-
'Warning: <p> cannot appear as a descendant ' +
2212-
'of <p>.' +
2212+
'Warning: In HTML, <p> cannot be a descendant ' +
2213+
'of <p>.\n' +
2214+
'This will cause a hydration error.' +
22132215
// There is no outer `p` here because root container is not part of the stack.
22142216
'\n in p (at **)' +
22152217
'\n in span (at **)',
@@ -2241,22 +2243,25 @@ describe('ReactDOMComponent', () => {
22412243
root.render(<Foo />);
22422244
});
22432245
}).toErrorDev([
2244-
'Warning: <tr> cannot appear as a child of ' +
2246+
'Warning: In HTML, <tr> cannot be a child of ' +
22452247
'<table>. Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated ' +
2246-
'by the browser.' +
2248+
'by the browser.\n' +
2249+
'This will cause a hydration error.' +
22472250
'\n in tr (at **)' +
22482251
'\n in Row (at **)' +
22492252
'\n in table (at **)' +
22502253
'\n in Foo (at **)',
2251-
'Warning: Text nodes cannot appear as a ' +
2252-
'child of <tr>.' +
2254+
'Warning: In HTML, text nodes cannot be a ' +
2255+
'child of <tr>.\n' +
2256+
'This will cause a hydration error.' +
22532257
'\n in tr (at **)' +
22542258
'\n in Row (at **)' +
22552259
'\n in table (at **)' +
22562260
'\n in Foo (at **)',
2257-
'Warning: Whitespace text nodes cannot ' +
2258-
"appear as a child of <table>. Make sure you don't have any extra " +
2259-
'whitespace between tags on each line of your source code.' +
2261+
'Warning: In HTML, whitespace text nodes cannot ' +
2262+
"be a child of <table>. Make sure you don't have any extra " +
2263+
'whitespace between tags on each line of your source code.\n' +
2264+
'This will cause a hydration error.' +
22602265
'\n in table (at **)' +
22612266
'\n in Foo (at **)',
22622267
]);
@@ -2283,9 +2288,10 @@ describe('ReactDOMComponent', () => {
22832288
root.render(<Foo> </Foo>);
22842289
});
22852290
}).toErrorDev([
2286-
'Warning: Whitespace text nodes cannot ' +
2287-
"appear as a child of <table>. Make sure you don't have any extra " +
2288-
'whitespace between tags on each line of your source code.' +
2291+
'Warning: In HTML, whitespace text nodes cannot ' +
2292+
"be a child of <table>. Make sure you don't have any extra " +
2293+
'whitespace between tags on each line of your source code.\n' +
2294+
'This will cause a hydration error.' +
22892295
'\n in table (at **)' +
22902296
'\n in Foo (at **)',
22912297
]);
@@ -2311,8 +2317,9 @@ describe('ReactDOMComponent', () => {
23112317
);
23122318
});
23132319
}).toErrorDev([
2314-
'Warning: Text nodes cannot appear as a ' +
2315-
'child of <tr>.' +
2320+
'Warning: In HTML, text nodes cannot be a ' +
2321+
'child of <tr>.\n' +
2322+
'This will cause a hydration error.' +
23162323
'\n in tr (at **)' +
23172324
'\n in Row (at **)' +
23182325
'\n in tbody (at **)' +

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ describe('ReactDOMFloat', () => {
523523
}).toErrorDev(
524524
[
525525
'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
526-
'Warning: <noscript> cannot appear as a child of <#document>.',
526+
'Warning: In HTML, <noscript> cannot be a child of <#document>.',
527527
],
528528
{withoutStack: 1},
529529
);
@@ -538,7 +538,7 @@ describe('ReactDOMFloat', () => {
538538
await waitForAll([]);
539539
}).toErrorDev([
540540
'Cannot render <template> outside the main document. Try moving it into the root <head> tag.',
541-
'Warning: <template> cannot appear as a child of <html>.',
541+
'Warning: In HTML, <template> cannot be a child of <html>.',
542542
]);
543543

544544
await expect(async () => {
@@ -551,7 +551,7 @@ describe('ReactDOMFloat', () => {
551551
await waitForAll([]);
552552
}).toErrorDev([
553553
'Cannot render a <style> outside the main document without knowing its precedence and a unique href key. React can hoist and deduplicate <style> tags if you provide a `precedence` prop along with an `href` prop that does not conflic with the `href` values used in any other hoisted <style> or <link rel="stylesheet" ...> tags. Note that hoisting <style> tags is considered an advanced feature that most will not use directly. Consider moving the <style> tag to the <head> or consider adding a `precedence="default"` and `href="some unique resource identifier"`, or move the <style> to the <style> tag.',
554-
'Warning: <style> cannot appear as a child of <html>.',
554+
'Warning: In HTML, <style> cannot be a child of <html>.',
555555
]);
556556

557557
await expect(async () => {
@@ -574,7 +574,7 @@ describe('ReactDOMFloat', () => {
574574
}).toErrorDev(
575575
[
576576
'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence. Consider adding precedence="default" or moving it into the root <head> tag.',
577-
'Warning: <link> cannot appear as a child of <#document>.',
577+
'Warning: In HTML, <link> cannot be a child of <#document>.',
578578
],
579579
{withoutStack: 1},
580580
);
@@ -591,7 +591,7 @@ describe('ReactDOMFloat', () => {
591591
await waitForAll([]);
592592
}).toErrorDev([
593593
'Cannot render a sync or defer <script> outside the main document without knowing its order. Try adding async="" or moving it into the root <head> tag.',
594-
'Warning: <script> cannot appear as a child of <html>.',
594+
'Warning: In HTML, <script> cannot be a child of <html>.',
595595
]);
596596

597597
await expect(async () => {
@@ -2552,11 +2552,11 @@ body {
25522552
'Cannot render a <style> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <style> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
25532553
'Cannot render a <link> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <link> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
25542554
'Cannot render a <script> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <script> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
2555-
'<meta> cannot appear as a child of <html>',
2556-
'<title> cannot appear as a child of <html>',
2557-
'<style> cannot appear as a child of <html>',
2558-
'<link> cannot appear as a child of <html>',
2559-
'<script> cannot appear as a child of <html>',
2555+
'In HTML, <meta> cannot be a child of <html>',
2556+
'In HTML, <title> cannot be a child of <html>',
2557+
'In HTML, <style> cannot be a child of <html>',
2558+
'In HTML, <link> cannot be a child of <html>',
2559+
'In HTML, <script> cannot be a child of <html>',
25602560
]);
25612561
});
25622562

packages/react-dom/src/__tests__/ReactDOMForm-test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ describe('ReactDOMForm', () => {
381381
);
382382
});
383383
}).toErrorDev([
384-
'Warning: <form> cannot appear as a descendant of <form>.' +
384+
'Warning: In HTML, <form> cannot be a descendant of <form>.\n' +
385+
'This will cause a hydration error.' +
385386
'\n in form (at **)' +
386387
'\n in form (at **)',
387388
]);

packages/react-dom/src/__tests__/ReactDOMOption-test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ describe('ReactDOMOption', () => {
4646
expect(() => {
4747
node = ReactTestUtils.renderIntoDocument(el);
4848
}).toErrorDev(
49-
'<div> cannot appear as a child of <option>.\n' +
49+
'In HTML, <div> cannot be a child of <option>.\n' +
50+
'This will cause a hydration error.\n' +
5051
' in div (at **)\n' +
5152
' in option (at **)',
5253
);
@@ -263,7 +264,7 @@ describe('ReactDOMOption', () => {
263264
[
264265
'Warning: Text content did not match. Server: "FooBaz" Client: "Foo"',
265266
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>',
266-
'Warning: <div> cannot appear as a child of <option>',
267+
'Warning: In HTML, <div> cannot be a child of <option>',
267268
],
268269
{withoutStack: 1},
269270
);

packages/react-dom/src/__tests__/validateDOMNesting-test.js

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,31 +60,42 @@ describe('validateDOMNesting', () => {
6060
it('prevents problematic nestings', () => {
6161
expectWarnings(
6262
['a', 'a'],
63-
['<a> cannot appear as a descendant of <a>.\n' + ' in a (at **)'],
63+
[
64+
'In HTML, <a> cannot be a descendant of <a>.\n' +
65+
'This will cause a hydration error.\n' +
66+
' in a (at **)',
67+
],
6468
);
6569
expectWarnings(
6670
['form', 'form'],
6771
[
68-
'<form> cannot appear as a descendant of <form>.\n' +
72+
'In HTML, <form> cannot be a descendant of <form>.\n' +
73+
'This will cause a hydration error.\n' +
6974
' in form (at **)',
7075
],
7176
);
7277
expectWarnings(
7378
['p', 'p'],
74-
['<p> cannot appear as a descendant of <p>.\n' + ' in p (at **)'],
79+
[
80+
'In HTML, <p> cannot be a descendant of <p>.\n' +
81+
'This will cause a hydration error.\n' +
82+
' in p (at **)',
83+
],
7584
);
7685
expectWarnings(
7786
['table', 'tr'],
7887
[
79-
'<tr> cannot appear as a child of <table>. ' +
88+
'In HTML, <tr> cannot be a child of <table>. ' +
8089
'Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by the browser.\n' +
90+
'This will cause a hydration error.\n' +
8191
' in tr (at **)',
8292
],
8393
);
8494
expectWarnings(
8595
['div', 'ul', 'li', 'div', 'li'],
8696
[
87-
'<li> cannot appear as a descendant of <li>.\n' +
97+
'In HTML, <li> cannot be a descendant of <li>.\n' +
98+
'This will cause a hydration error.\n' +
8899
' in li (at **)\n' +
89100
' in div (at **)\n' +
90101
' in li (at **)\n' +
@@ -93,16 +104,26 @@ describe('validateDOMNesting', () => {
93104
);
94105
expectWarnings(
95106
['div', 'html'],
96-
['<html> cannot appear as a child of <div>.\n' + ' in html (at **)'],
107+
[
108+
'In HTML, <html> cannot be a child of <div>.\n' +
109+
'This will cause a hydration error.\n' +
110+
' in html (at **)',
111+
],
97112
);
98113
expectWarnings(
99114
['body', 'body'],
100-
['<body> cannot appear as a child of <body>.\n' + ' in body (at **)'],
115+
[
116+
'In HTML, <body> cannot be a child of <body>.\n' +
117+
'This will cause a hydration error.\n' +
118+
' in body (at **)',
119+
],
101120
);
102121
expectWarnings(
103122
['svg', 'foreignObject', 'body', 'p'],
104123
[
105-
'<body> cannot appear as a child of <foreignObject>.\n' +
124+
// TODO, this should say "In SVG",
125+
'In HTML, <body> cannot be a child of <foreignObject>.\n' +
126+
'This will cause a hydration error.\n' +
106127
' in body (at **)\n' +
107128
' in foreignObject (at **)',
108129
'Warning: You are mounting a new body component when a previous one has not first unmounted. It is an error to render more than one body component at a time and attributes and children of these components will likely fail in unpredictable ways. Please only render a single instance of <body> and if you need to mount a new one, ensure any previous ones have unmounted first.\n' +

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,12 +1512,12 @@ function updateHostComponent(
15121512
workInProgress: Fiber,
15131513
renderLanes: Lanes,
15141514
) {
1515-
pushHostContext(workInProgress);
1516-
15171515
if (current === null) {
15181516
tryToClaimNextHydratableInstance(workInProgress);
15191517
}
15201518

1519+
pushHostContext(workInProgress);
1520+
15211521
const type = workInProgress.type;
15221522
const nextProps = workInProgress.pendingProps;
15231523
const prevProps = current !== null ? current.memoizedProps : null;

0 commit comments

Comments
 (0)