[Fiber][Dev] Relax dom nesting validation when the root is a Document, html tag, or body tag (#32252)
followup to * https://github.com/facebook/react/pull/32069 * https://github.com/facebook/react/pull/32163 * https://github.com/facebook/react/pull/32224 in react-dom in Dev we validate that the tag nesting is valid. This is motivated primarily because while browsers are tolerant to poor HTML there are many cases that if server rendered will be hydrated in a way that will break hydration. With the changes to singleton scoping where the document body is now the implicit render/hydration context for arbitrary tags at the root we need to adjust the validation logic to allow for valid programs such as rendering divs as a child of a Document (since this div will actually insert into the body).
Josh Story committed
Feb 6, 2025 at 15:05 UTC
a0fdb6306043b9f049106e58dcec107d8dbed2b1
7 files changed
+175
-49
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+2
-2
@@ -344,7 +344,7 @@ function setProp(
344
case 'children': {
345
if (typeof value === 'string') {
346
if (__DEV__) {
347
- validateTextNesting(value, tag);
347
+ validateTextNesting(value, tag, false);
348
}
349
// Avoid setting initial textContent when the text is empty. In IE11 setting
350
// textContent on a <textarea> will cause the placeholder to not
@@ -358,7 +358,7 @@ function setProp(
358
} else if (typeof value === 'number' || typeof value === 'bigint') {
359
if (__DEV__) {
360
// $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint
361
- validateTextNesting('' + value, tag);
361
+ validateTextNesting('' + value, tag, false);
362
}
363
const canSetTextContent = tag !== 'body';
364
if (canSetTextContent) {
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+19
-4
@@ -595,7 +595,11 @@ export function createTextInstance(
595
const hostContextDev = ((hostContext: any): HostContextDev);
596
const ancestor = hostContextDev.ancestorInfo.current;
597
if (ancestor != null) {
598
- validateTextNesting(text, ancestor.tag);
598
+ validateTextNesting(
599
+ text,
600
+ ancestor.tag,
601
+ hostContextDev.ancestorInfo.implicitRootScope,
602
+ );
603
}
604
}
605
const textNode: TextInstance = getOwnerDocumentFromRootContainer(
@@ -2046,7 +2050,11 @@ export function validateHydratableTextInstance(
2050
const hostContextDev = ((hostContext: any): HostContextDev);
2051
const ancestor = hostContextDev.ancestorInfo.current;
2052
if (ancestor != null) {
2049
- return validateTextNesting(text, ancestor.tag);
2053
+ return validateTextNesting(
2054
+ text,
2055
+ ancestor.tag,
2056
+ hostContextDev.ancestorInfo.implicitRootScope,
2057
+ );
2058
}
2059
}
2060
return true;
@@ -2394,8 +2402,15 @@ export function acquireSingletonInstance(
2402
internalInstanceHandle: Object,
2403
): void {
2404
if (__DEV__) {
2397
- const currentInstanceHandle = getInstanceFromNodeDOMTree(instance);
2398
- if (currentInstanceHandle) {
2405
+ if (
2406
+ // If this instance is the container then it is invalid to acquire it as a singleton however
2407
+ // the DOM nesting validation will already warn for this and the message below isn't semantically
2408
+ // aligned with the actual fix you need to make so we omit the warning in this case
2409
+ !isContainerMarkedAsRoot(instance) &&
2410
+ // If this instance isn't the root but is currently owned by a different HostSingleton instance then
2411
+ // we we need to warn that you are rendering more than one singleton at a time.
2412
+ getInstanceFromNodeDOMTree(instance)
2413
+ ) {
2414
const tagName = instance.tagName.toLowerCase();
2415
console.error(
2416
'You are mounting a new %s component when a previous one has not first unmounted. It is an' +
packages/react-dom-bindings/src/client/validateDOMNesting.js
+62
-10
@@ -71,6 +71,7 @@ export type AncestorInfoDev = {
71
72
// <head> or <body>
73
containerTagInScope: ?Info,
74
+ implicitRootScope: boolean,
75
};
76
77
// This validation code was written based on the HTML5 parsing spec:
@@ -219,10 +220,11 @@ const emptyAncestorInfoDev: AncestorInfoDev = {
220
dlItemTagAutoclosing: null,
221
222
containerTagInScope: null,
223
+ implicitRootScope: false,
224
};
225
226
function updatedAncestorInfoDev(
225
- oldInfo: ?AncestorInfoDev,
227
+ oldInfo: null | AncestorInfoDev,
228
tag: string,
229
): AncestorInfoDev {
230
if (__DEV__) {
@@ -238,14 +240,14 @@ function updatedAncestorInfoDev(
240
ancestorInfo.pTagInButtonScope = null;
241
}
242
241
- // See rules for 'li', 'dd', 'dt' start tags in
242
- // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
243
if (
244
specialTags.indexOf(tag) !== -1 &&
245
tag !== 'address' &&
246
tag !== 'div' &&
247
tag !== 'p'
248
) {
249
+ // See rules for 'li', 'dd', 'dt' start tags in
250
+ // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
251
ancestorInfo.listItemTagAutoclosing = null;
252
ancestorInfo.dlItemTagAutoclosing = null;
253
}
@@ -279,6 +281,17 @@ function updatedAncestorInfoDev(
281
ancestorInfo.containerTagInScope = info;
282
}
283
284
+ if (
285
+ oldInfo === null &&
286
+ (tag === '#document' || tag === 'html' || tag === 'body')
287
+ ) {
288
+ // While <head> is also a singleton we don't want to support semantics where
289
+ // you can escape the head by rendering a body singleton so we treat it like a normal scope
290
+ ancestorInfo.implicitRootScope = true;
291
+ } else if (ancestorInfo.implicitRootScope === true) {
292
+ ancestorInfo.implicitRootScope = false;
293
+ }
294
+
295
return ancestorInfo;
296
} else {
297
return (null: any);
@@ -288,7 +301,11 @@ function updatedAncestorInfoDev(
301
/**
302
* Returns whether
303
*/
291
-function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
304
+function isTagValidWithParent(
305
+ tag: string,
306
+ parentTag: ?string,
307
+ implicitRootScope: boolean,
308
+): boolean {
309
// First, let's check if we're in an unusual parsing mode...
310
switch (parentTag) {
311
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
@@ -363,10 +380,22 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
380
);
381
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
382
case 'html':
383
+ if (implicitRootScope) {
384
+ // When our parent tag is html and we're in the root scope we will actually
385
+ // insert most tags into the body so we need to fall through to validating
386
+ // the specific tag with "in body" parsing mode below
387
+ break;
388
+ }
389
return tag === 'head' || tag === 'body' || tag === 'frameset';
390
case 'frameset':
391
return tag === 'frame';
392
case '#document':
393
+ if (implicitRootScope) {
394
+ // When our parent is the Document and we're in the root scope we will actually
395
+ // insert most tags into the body so we need to fall through to validating
396
+ // the specific tag with "in body" parsing mode below
397
+ break;
398
+ }
399
return tag === 'html';
400
}
401
@@ -393,14 +422,11 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
422
case 'rt':
423
return impliedEndTags.indexOf(parentTag) === -1;
424
396
- case 'body':
425
case 'caption':
426
case 'col':
427
case 'colgroup':
428
case 'frameset':
429
case 'frame':
402
- case 'head':
403
- case 'html':
430
case 'tbody':
431
case 'td':
432
case 'tfoot':
@@ -412,6 +438,24 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
438
// so we allow it only if we don't know what the parent is, as all other
439
// cases are invalid.
440
return parentTag == null;
441
+ case 'head':
442
+ // We support rendering <head> in the root when the container is
443
+ // #document, <html>, or <body>.
444
+ return implicitRootScope || parentTag === null;
445
+ case 'html':
446
+ // We support rendering <html> in the root when the container is
447
+ // #document
448
+ return (
449
+ (implicitRootScope && parentTag === '#document') || parentTag === null
450
+ );
451
+ case 'body':
452
+ // We support rendering <body> in the root when the container is
453
+ // #document or <html>
454
+ return (
455
+ (implicitRootScope &&
456
+ (parentTag === '#document' || parentTag === 'html')) ||
457
+ parentTag === null
458
+ );
459
}
460
461
return true;
@@ -513,7 +557,11 @@ function validateDOMNesting(
557
const parentInfo = ancestorInfo.current;
558
const parentTag = parentInfo && parentInfo.tag;
559
516
- const invalidParent = isTagValidWithParent(childTag, parentTag)
560
+ const invalidParent = isTagValidWithParent(
561
+ childTag,
562
+ parentTag,
563
+ ancestorInfo.implicitRootScope,
564
+ )
565
? null
566
: parentInfo;
567
const invalidAncestor = invalidParent
@@ -594,9 +642,13 @@ function validateDOMNesting(
642
return true;
643
}
644
597
-function validateTextNesting(childText: string, parentTag: string): boolean {
645
+function validateTextNesting(
646
+ childText: string,
647
+ parentTag: string,
648
+ implicitRootScope: boolean,
649
+): boolean {
650
if (__DEV__) {
599
- if (isTagValidWithParent('#text', parentTag)) {
651
+ if (implicitRootScope || isTagValidWithParent('#text', parentTag, false)) {
652
return true;
653
}
654
packages/react-dom/src/__tests__/ReactDOM-test.js
-12
@@ -601,10 +601,6 @@ describe('ReactDOM', () => {
601
'<html lang="en"><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
602
);
603
604
- // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
605
- // root of the application
606
- assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
607
-
604
await act(() => {
605
root.render(<App phase={1} />);
606
});
@@ -666,10 +662,6 @@ describe('ReactDOM', () => {
662
'<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
663
);
664
669
- // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
670
- // root of the application
671
- assertConsoleErrorDev(['In HTML, <div> cannot be a child of <html>']);
672
-
665
await act(() => {
666
root.render(<App phase={1} />);
667
});
@@ -729,10 +721,6 @@ describe('ReactDOM', () => {
721
'<html><head data-h=""><meta itemprop="" content="head"></head><body><div>before</div><div>inside</div><div>after</div></body></html>',
722
);
723
732
- // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
733
- // root of the application
734
- assertConsoleErrorDev(['In HTML, <head> cannot be a child of <body>']);
735
-
724
await act(() => {
725
root.render(<App phase={1} />);
726
});
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
-2
@@ -9004,7 +9004,6 @@ describe('ReactDOMFizzServer', () => {
9004
</body>
9005
</html>,
9006
);
9007
- assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
9007
9008
root.unmount();
9009
expect(getVisibleChildren(document)).toEqual(
@@ -10173,7 +10172,6 @@ describe('ReactDOMFizzServer', () => {
10172
</body>
10173
</html>,
10174
);
10176
- assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
10175
10176
root.unmount();
10177
expect(getVisibleChildren(document)).toEqual(
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
-6
@@ -511,9 +511,6 @@ describe('ReactDOMFloat', () => {
511
'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
512
{withoutStack: true},
513
],
514
- 'In HTML, <noscript> cannot be a child of <#document>.\n' +
515
- 'This will cause a hydration error.\n' +
516
- ' in noscript (at **)',
514
]);
515
516
root.render(
@@ -577,9 +574,6 @@ describe('ReactDOMFloat', () => {
574
'Consider adding precedence="default" or moving it into the root <head> tag.',
575
{withoutStack: true},
576
],
580
- 'In HTML, <link> cannot be a child of <#document>.\n' +
581
- 'This will cause a hydration error.\n' +
582
- ' in link (at **)',
577
]);
578
579
root.render(
packages/react-dom/src/__tests__/validateDOMNesting-test.js
+92
-13
@@ -19,28 +19,54 @@ function expectWarnings(tags, warnings = [], withoutStack = 0) {
19
tags = [...tags];
20
warnings = [...warnings];
21
22
+ document.removeChild(document.documentElement);
23
+ document.appendChild(document.createElement('html'));
24
+ document.documentElement.innerHTML = '<head></head><body></body>';
25
+
26
let element = null;
27
const containerTag = tags.shift();
24
- const container =
25
- containerTag === 'svg'
26
- ? document.createElementNS('http://www.w3.org/2000/svg', containerTag)
27
- : document.createElement(containerTag);
28
+ let container;
29
+ switch (containerTag) {
30
+ case '#document':
31
+ container = document;
32
+ break;
33
+ case 'html':
34
+ container = document.documentElement;
35
+ break;
36
+ case 'body':
37
+ container = document.body;
38
+ break;
39
+ case 'head':
40
+ container = document.head;
41
+ break;
42
+ case 'svg':
43
+ container = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
44
+ break;
45
+ default:
46
+ container = document.createElement(containerTag);
47
+ break;
48
+ }
49
50
while (tags.length) {
51
const Tag = tags.pop();
31
- element = <Tag>{element}</Tag>;
52
+ if (Tag === '#text') {
53
+ element = 'text';
54
+ } else {
55
+ element = <Tag>{element}</Tag>;
56
+ }
57
}
58
59
const root = ReactDOMClient.createRoot(container);
60
+ ReactDOM.flushSync(() => {
61
+ root.render(element);
62
+ });
63
if (warnings.length) {
36
- ReactDOM.flushSync(() => {
37
- root.render(element);
38
- });
64
assertConsoleErrorDev(
65
warnings,
66
withoutStack > 0 ? {withoutStack} : undefined,
67
);
68
}
69
+ root.unmount();
70
}
71
72
describe('validateDOMNesting', () => {
@@ -140,6 +166,46 @@ describe('validateDOMNesting', () => {
166
' in body (at **)',
167
],
168
);
169
+ expectWarnings(
170
+ ['head', 'body'],
171
+ [
172
+ 'In HTML, <body> cannot be a child of <head>.\n' +
173
+ 'This will cause a hydration error.\n' +
174
+ ' in body (at **)',
175
+ ],
176
+ );
177
+ expectWarnings(
178
+ ['head', 'head'],
179
+ [
180
+ 'In HTML, <head> cannot be a child of <head>.\n' +
181
+ 'This will cause a hydration error.\n' +
182
+ ' in head (at **)',
183
+ ],
184
+ );
185
+ expectWarnings(
186
+ ['html', 'html'],
187
+ [
188
+ 'In HTML, <html> cannot be a child of <html>.\n' +
189
+ 'This will cause a hydration error.\n' +
190
+ ' in html (at **)',
191
+ ],
192
+ );
193
+ expectWarnings(
194
+ ['body', 'html'],
195
+ [
196
+ 'In HTML, <html> cannot be a child of <body>.\n' +
197
+ 'This will cause a hydration error.\n' +
198
+ ' in html (at **)',
199
+ ],
200
+ );
201
+ expectWarnings(
202
+ ['head', 'html'],
203
+ [
204
+ 'In HTML, <html> cannot be a child of <head>.\n' +
205
+ 'This will cause a hydration error.\n' +
206
+ ' in html (at **)',
207
+ ],
208
+ );
209
expectWarnings(
210
['svg', 'foreignObject', 'body', 'p'],
211
gate(flags => flags.enableOwnerStacks)
@@ -152,8 +218,6 @@ describe('validateDOMNesting', () => {
218
'> <body>\n' +
219
'\n' +
220
' in body (at **)',
155
- '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' +
156
- ' in body (at **)',
221
]
222
: [
223
// TODO, this should say "In SVG",
@@ -165,10 +229,25 @@ describe('validateDOMNesting', () => {
229
'\n' +
230
' in body (at **)\n' +
231
' in foreignObject (at **)',
168
- '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' +
169
- ' in body (at **)\n' +
170
- ' in foreignObject (at **)',
232
],
233
);
234
});
235
+
236
+ it('relaxes the nesting rules at the root when the container is a singleton', () => {
237
+ expectWarnings(['#document', 'html']);
238
+ expectWarnings(['#document', 'body']);
239
+ expectWarnings(['#document', 'head']);
240
+ expectWarnings(['#document', 'div']);
241
+ expectWarnings(['#document', 'meta']);
242
+ expectWarnings(['#document', '#text']);
243
+ expectWarnings(['html', 'body']);
244
+ expectWarnings(['html', 'head']);
245
+ expectWarnings(['html', 'div']);
246
+ expectWarnings(['html', 'meta']);
247
+ expectWarnings(['html', '#text']);
248
+ expectWarnings(['body', 'head']);
249
+ expectWarnings(['body', 'div']);
250
+ expectWarnings(['body', 'meta']);
251
+ expectWarnings(['body', '#text']);
252
+ });
253
});