Skip to content

Commit

Permalink
[Fiber] relax DOM validation rules at root
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
gnoff committed Jan 28, 2025
1 parent c40b0ae commit aee09bb
Show file tree
Hide file tree
Showing 4 changed files with 25 additions and 22 deletions.
31 changes: 25 additions & 6 deletions packages/react-dom-bindings/src/client/validateDOMNesting.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export type AncestorInfoDev = {

// <head> or <body>
containerTagInScope: ?Info,
implicitBodyScope: boolean,
};

// This validation code was written based on the HTML5 parsing spec:
Expand Down Expand Up @@ -219,16 +220,19 @@ const emptyAncestorInfoDev: AncestorInfoDev = {
dlItemTagAutoclosing: null,

containerTagInScope: null,
implicitBodyScope: false,
};

function updatedAncestorInfoDev(
oldInfo: ?AncestorInfoDev,
oldInfo: null | AncestorInfoDev,
tag: string,
): AncestorInfoDev {
if (__DEV__) {
const ancestorInfo = {...(oldInfo || emptyAncestorInfoDev)};
const info = {tag};

ancestorInfo.implicitBodyScope = false;

if (inScopeTags.indexOf(tag) !== -1) {
ancestorInfo.aTagInScope = null;
ancestorInfo.buttonTagInScope = null;
Expand All @@ -238,14 +242,14 @@ function updatedAncestorInfoDev(
ancestorInfo.pTagInButtonScope = null;
}

// See rules for 'li', 'dd', 'dt' start tags in
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
if (
specialTags.indexOf(tag) !== -1 &&
tag !== 'address' &&
tag !== 'div' &&
tag !== 'p'
) {
// See rules for 'li', 'dd', 'dt' start tags in
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
ancestorInfo.listItemTagAutoclosing = null;
ancestorInfo.dlItemTagAutoclosing = null;
}
Expand Down Expand Up @@ -274,6 +278,10 @@ function updatedAncestorInfoDev(
ancestorInfo.dlItemTagAutoclosing = info;
}
if (tag === '#document' || tag === 'html') {
if (oldInfo === null) {
// We're at the root with implicit body scope
ancestorInfo.implicitBodyScope = true;
}
ancestorInfo.containerTagInScope = null;
} else if (!ancestorInfo.containerTagInScope) {
ancestorInfo.containerTagInScope = info;
Expand Down Expand Up @@ -363,11 +371,16 @@ function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
);
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
case 'html':
return tag === 'head' || tag === 'body' || tag === 'frameset';
return (
tag === 'head' ||
tag === 'body' ||
tag === 'frameset' ||
parentTag === null
);
case 'frameset':
return tag === 'frame';
case '#document':
return tag === 'html';
return tag === 'html' || parentTag === null;
}

// Probably in the "in body" parsing mode, so we outlaw only tag combos
Expand Down Expand Up @@ -511,7 +524,13 @@ function validateDOMNesting(
if (__DEV__) {
ancestorInfo = ancestorInfo || emptyAncestorInfoDev;
const parentInfo = ancestorInfo.current;
const parentTag = parentInfo && parentInfo.tag;
const parentTag =
parentInfo &&
// If we are in implicit body scope we validate as if we have no parent.
// This is because we expect the host to insert the tag into the necessary context
ancestorInfo.implicitBodyScope === false
? parentInfo.tag
: null;

const invalidParent = isTagValidWithParent(childTag, parentTag)
? null
Expand Down
8 changes: 0 additions & 8 deletions packages/react-dom/src/__tests__/ReactDOM-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,10 +601,6 @@ describe('ReactDOM', () => {
'<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>',
);

// @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
// root of the application
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);

await act(() => {
root.render(<App phase={1} />);
});
Expand Down Expand Up @@ -666,10 +662,6 @@ describe('ReactDOM', () => {
'<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
);

// @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
// root of the application
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <html>']);

await act(() => {
root.render(<App phase={1} />);
});
Expand Down
2 changes: 0 additions & 2 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9004,7 +9004,6 @@ describe('ReactDOMFizzServer', () => {
</body>
</html>,
);
assertConsoleErrorDev(['In HTML, <div> cannot be a child of <#document>']);

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

root.unmount();
expect(getVisibleChildren(document)).toEqual(
Expand Down
6 changes: 0 additions & 6 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,6 @@ describe('ReactDOMFloat', () => {
'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
{withoutStack: true},
],
'In HTML, <noscript> cannot be a child of <#document>.\n' +
'This will cause a hydration error.\n' +
' in noscript (at **)',
]);

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

root.render(
Expand Down

0 comments on commit aee09bb

Please sign in to comment.