Skip to content

Commit 163de10

Browse files
committed
[Fiber] relax DOM validation rules at root
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).
1 parent c40b0ae commit 163de10

File tree

7 files changed

+156
-44
lines changed

7 files changed

+156
-44
lines changed

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ function setProp(
344344
case 'children': {
345345
if (typeof value === 'string') {
346346
if (__DEV__) {
347-
validateTextNesting(value, tag);
347+
validateTextNesting(value, tag, false);
348348
}
349349
// Avoid setting initial textContent when the text is empty. In IE11 setting
350350
// textContent on a <textarea> will cause the placeholder to not
@@ -358,7 +358,7 @@ function setProp(
358358
} else if (typeof value === 'number' || typeof value === 'bigint') {
359359
if (__DEV__) {
360360
// $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint
361-
validateTextNesting('' + value, tag);
361+
validateTextNesting('' + value, tag, false);
362362
}
363363
const canSetTextContent = tag !== 'body';
364364
if (canSetTextContent) {

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

+10-2
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,11 @@ export function createTextInstance(
594594
const hostContextDev = ((hostContext: any): HostContextDev);
595595
const ancestor = hostContextDev.ancestorInfo.current;
596596
if (ancestor != null) {
597-
validateTextNesting(text, ancestor.tag);
597+
validateTextNesting(
598+
text,
599+
ancestor.tag,
600+
hostContextDev.ancestorInfo.implicitRootScope,
601+
);
598602
}
599603
}
600604
const textNode: TextInstance = getOwnerDocumentFromRootContainer(
@@ -2027,7 +2031,11 @@ export function validateHydratableTextInstance(
20272031
const hostContextDev = ((hostContext: any): HostContextDev);
20282032
const ancestor = hostContextDev.ancestorInfo.current;
20292033
if (ancestor != null) {
2030-
return validateTextNesting(text, ancestor.tag);
2034+
return validateTextNesting(
2035+
text,
2036+
ancestor.tag,
2037+
hostContextDev.ancestorInfo.implicitRootScope,
2038+
);
20312039
}
20322040
}
20332041
return true;

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

+53-10
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export type AncestorInfoDev = {
7171

7272
// <head> or <body>
7373
containerTagInScope: ?Info,
74+
implicitRootScope: boolean,
7475
};
7576

7677
// This validation code was written based on the HTML5 parsing spec:
@@ -219,10 +220,11 @@ const emptyAncestorInfoDev: AncestorInfoDev = {
219220
dlItemTagAutoclosing: null,
220221

221222
containerTagInScope: null,
223+
implicitRootScope: false,
222224
};
223225

224226
function updatedAncestorInfoDev(
225-
oldInfo: ?AncestorInfoDev,
227+
oldInfo: null | AncestorInfoDev,
226228
tag: string,
227229
): AncestorInfoDev {
228230
if (__DEV__) {
@@ -238,14 +240,14 @@ function updatedAncestorInfoDev(
238240
ancestorInfo.pTagInButtonScope = null;
239241
}
240242

241-
// See rules for 'li', 'dd', 'dt' start tags in
242-
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
243243
if (
244244
specialTags.indexOf(tag) !== -1 &&
245245
tag !== 'address' &&
246246
tag !== 'div' &&
247247
tag !== 'p'
248248
) {
249+
// See rules for 'li', 'dd', 'dt' start tags in
250+
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
249251
ancestorInfo.listItemTagAutoclosing = null;
250252
ancestorInfo.dlItemTagAutoclosing = null;
251253
}
@@ -279,6 +281,17 @@ function updatedAncestorInfoDev(
279281
ancestorInfo.containerTagInScope = info;
280282
}
281283

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+
282295
return ancestorInfo;
283296
} else {
284297
return (null: any);
@@ -288,7 +301,11 @@ function updatedAncestorInfoDev(
288301
/**
289302
* Returns whether
290303
*/
291-
function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
304+
function isTagValidWithParent(
305+
tag: string,
306+
parentTag: ?string,
307+
implicitRootScope: boolean,
308+
): boolean {
292309
// First, let's check if we're in an unusual parsing mode...
293310
switch (parentTag) {
294311
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
@@ -363,10 +380,16 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
363380
);
364381
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
365382
case 'html':
383+
if (implicitRootScope) {
384+
break;
385+
}
366386
return tag === 'head' || tag === 'body' || tag === 'frameset';
367387
case 'frameset':
368388
return tag === 'frame';
369389
case '#document':
390+
if (implicitRootScope) {
391+
break;
392+
}
370393
return tag === 'html';
371394
}
372395

@@ -393,14 +416,11 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
393416
case 'rt':
394417
return impliedEndTags.indexOf(parentTag) === -1;
395418

396-
case 'body':
397419
case 'caption':
398420
case 'col':
399421
case 'colgroup':
400422
case 'frameset':
401423
case 'frame':
402-
case 'head':
403-
case 'html':
404424
case 'tbody':
405425
case 'td':
406426
case 'tfoot':
@@ -412,6 +432,21 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
412432
// so we allow it only if we don't know what the parent is, as all other
413433
// cases are invalid.
414434
return parentTag == null;
435+
case 'head':
436+
return (implicitRootScope && parentTag !== 'head') || parentTag === null;
437+
case 'html':
438+
return (
439+
(implicitRootScope &&
440+
parentTag !== 'body' &&
441+
parentTag !== 'html' &&
442+
parentTag !== 'head') ||
443+
parentTag === null
444+
);
445+
case 'body':
446+
return (
447+
(implicitRootScope && parentTag !== 'body' && parentTag !== 'head') ||
448+
parentTag === null
449+
);
415450
}
416451

417452
return true;
@@ -513,7 +548,11 @@ function validateDOMNesting(
513548
const parentInfo = ancestorInfo.current;
514549
const parentTag = parentInfo && parentInfo.tag;
515550

516-
const invalidParent = isTagValidWithParent(childTag, parentTag)
551+
const invalidParent = isTagValidWithParent(
552+
childTag,
553+
parentTag,
554+
ancestorInfo.implicitRootScope,
555+
)
517556
? null
518557
: parentInfo;
519558
const invalidAncestor = invalidParent
@@ -594,9 +633,13 @@ function validateDOMNesting(
594633
return true;
595634
}
596635

597-
function validateTextNesting(childText: string, parentTag: string): boolean {
636+
function validateTextNesting(
637+
childText: string,
638+
parentTag: string,
639+
implicitRootScope: boolean,
640+
): boolean {
598641
if (__DEV__) {
599-
if (isTagValidWithParent('#text', parentTag)) {
642+
if (implicitRootScope || isTagValidWithParent('#text', parentTag)) {
600643
return true;
601644
}
602645

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

-12
Original file line numberDiff line numberDiff line change
@@ -601,10 +601,6 @@ describe('ReactDOM', () => {
601601
'<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>',
602602
);
603603

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-
608604
await act(() => {
609605
root.render(<App phase={1} />);
610606
});
@@ -666,10 +662,6 @@ describe('ReactDOM', () => {
666662
'<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
667663
);
668664

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-
673665
await act(() => {
674666
root.render(<App phase={1} />);
675667
});
@@ -729,10 +721,6 @@ describe('ReactDOM', () => {
729721
'<html><head data-h=""><meta itemprop="" content="head"></head><body><div>before</div><div>inside</div><div>after</div></body></html>',
730722
);
731723

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-
736724
await act(() => {
737725
root.render(<App phase={1} />);
738726
});

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

-2
Original file line numberDiff line numberDiff line change
@@ -9004,7 +9004,6 @@ describe('ReactDOMFizzServer', () => {
90049004
</body>
90059005
</html>,
90069006
);
9007-
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
90089007

90099008
root.unmount();
90109009
expect(getVisibleChildren(document)).toEqual(
@@ -10173,7 +10172,6 @@ describe('ReactDOMFizzServer', () => {
1017310172
</body>
1017410173
</html>,
1017510174
);
10176-
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);
1017710175

1017810176
root.unmount();
1017910177
expect(getVisibleChildren(document)).toEqual(

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

-6
Original file line numberDiff line numberDiff line change
@@ -511,9 +511,6 @@ describe('ReactDOMFloat', () => {
511511
'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
512512
{withoutStack: true},
513513
],
514-
'In HTML, <noscript> cannot be a child of <#document>.\n' +
515-
'This will cause a hydration error.\n' +
516-
' in noscript (at **)',
517514
]);
518515

519516
root.render(
@@ -577,9 +574,6 @@ describe('ReactDOMFloat', () => {
577574
'Consider adding precedence="default" or moving it into the root <head> tag.',
578575
{withoutStack: true},
579576
],
580-
'In HTML, <link> cannot be a child of <#document>.\n' +
581-
'This will cause a hydration error.\n' +
582-
' in link (at **)',
583577
]);
584578

585579
root.render(

0 commit comments

Comments
 (0)