@samitouri / QOS-React / commits / a0fdb63060

[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 });