Skip to content

Commit 1818f29

Browse files
committed
Retain documentElement when appending/removing from Document
creating a root or hydrating into a root with a Document container could error in surprising ways because the document root element was not removed when clearing the container and then a new html element was attempting to be appended to the document. Browsers do not allow more than one root element and this errored leading to a cascading failure where all content was unmounted from the tree breaking the application. This change changes the behavior of clearContainer, appendChildToContainer, and removeChildFromContainer clearContainer on a Document container will now remove all children of the documentElement (usually <html>) appendChildToContainer on a Document container will now 'adopt' the existing documentElement, transferring all stateNode properties and children over to it rather than dirctly appending the child removeChildFromContainer on a Document container will now remove all children of the documentElement but leave the documentElement in place
1 parent 99eef9e commit 1818f29

File tree

4 files changed

+177
-33
lines changed

4 files changed

+177
-33
lines changed

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,82 @@ describe('ReactDOMFizzServer', () => {
576576
expect(loggedErrors).toEqual([theError]);
577577
});
578578

579+
// @gate experimental
580+
it('#22833 should not error when client rendering a fallback at the document root (html element)', async () => {
581+
function App({isClient}) {
582+
return (
583+
<html>
584+
<head>
585+
<title>an introduction</title>
586+
</head>
587+
<body>
588+
<p>hi</p>
589+
<p>{isClient ? 'hello client' : 'hello server'}</p>
590+
</body>
591+
</html>
592+
);
593+
}
594+
595+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
596+
<App isClient={false} />,
597+
);
598+
pipe(writable);
599+
await new Promise(resolve => {
600+
setImmediate(resolve);
601+
});
602+
603+
// Test Environment
604+
const jsdom = new JSDOM(buffer, {
605+
runScripts: 'dangerously',
606+
});
607+
buffer = '';
608+
window = jsdom.window;
609+
document = jsdom.window.document;
610+
container = document;
611+
612+
// Attempt to hydrate the content.
613+
ReactDOMClient.hydrateRoot(document, <App isClient={true} />, {
614+
onRecoverableError(error) {
615+
Scheduler.unstable_yieldValue(error.message);
616+
},
617+
});
618+
619+
const assertion = () => {
620+
expect(Scheduler).toFlushAndYield([
621+
'Text content does not match server-rendered HTML.',
622+
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
623+
]);
624+
};
625+
626+
if (__DEV__) {
627+
expect(assertion).toErrorDev(
628+
[
629+
'Warning: Text content did not match. Server: "hello server" Client: "hello client"\n' +
630+
' in p (at **)\n' +
631+
' in body (at **)\n' +
632+
' in html (at **)',
633+
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
634+
],
635+
{withoutStack: 1},
636+
);
637+
} else {
638+
assertion();
639+
}
640+
641+
// We're still loading because we're waiting for the server to stream more content.
642+
expect(getVisibleChildren(container)).toEqual(
643+
<html>
644+
<head>
645+
<title>an introduction</title>
646+
</head>
647+
<body>
648+
<p>hi</p>
649+
<p>hello client</p>
650+
</body>
651+
</html>,
652+
);
653+
});
654+
579655
// @gate experimental
580656
it('should asynchronously load the suspense boundary', async () => {
581657
await act(async () => {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ describe('rendering React components at document', () => {
6262
expect(body === testDocument.body).toBe(true);
6363
});
6464

65-
it('should not be able to unmount component from document node', () => {
65+
// @TODO This test should now fail since we leave the documentElement in place even when we unmount
66+
// from a Document container. It probably just amkes senese to reframe this test to reflect that you
67+
// are left with an empty html element rather than no children at all
68+
xit('should not be able to unmount component from document node', () => {
6669
class Root extends React.Component {
6770
render() {
6871
return (

packages/react-dom/src/client/ReactDOMComponentTree.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,42 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
153153
return null;
154154
}
155155

156+
/**
157+
* Given an original DOM node attached to a Fiber, replace it with the
158+
* replacement DOM node, detatching the original in the process
159+
*
160+
* This function throws if the original node is not attached to a Fiber
161+
*/
162+
export function replaceNode(original: Node, replacement: Node): void {
163+
const fiber = original[internalInstanceKey];
164+
if (fiber == null) {
165+
throw new Error(
166+
'replaceNode expected the original DOM node to have a fiber reference but one was not found. this is a bug in React, please file an issue.',
167+
);
168+
}
169+
fiber.stateNode = replacement;
170+
replacement[internalInstanceKey] = original[internalInstanceKey];
171+
delete original[internalInstanceKey];
172+
if (internalPropsKey in original) {
173+
replacement[internalPropsKey] = original[internalPropsKey];
174+
delete original[internalPropsKey];
175+
}
176+
if (internalEventHandlersKey in original) {
177+
replacement[internalEventHandlersKey] = original[internalEventHandlersKey];
178+
delete original[internalEventHandlersKey];
179+
}
180+
if (internalEventHandlerListenersKey in original) {
181+
replacement[internalEventHandlerListenersKey] =
182+
original[internalEventHandlerListenersKey];
183+
delete original[internalEventHandlerListenersKey];
184+
}
185+
if (internalEventHandlesSetKey in original) {
186+
replacement[internalEventHandlesSetKey] =
187+
original[internalEventHandlesSetKey];
188+
delete original[internalEventHandlesSetKey];
189+
}
190+
}
191+
156192
/**
157193
* Given a DOM node, return the ReactDOMComponent or ReactDOMTextComponent
158194
* instance, or null if the node was not rendered by this React.

packages/react-dom/src/client/ReactDOMHostConfig.js

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {
2323
getFiberFromScopeInstance,
2424
getInstanceFromNode as getInstanceFromNodeDOMTree,
2525
isContainerMarkedAsRoot,
26+
replaceNode,
27+
detachDeletedInstance,
2628
} from './ReactDOMComponentTree';
27-
export {detachDeletedInstance} from './ReactDOMComponentTree';
29+
export {detachDeletedInstance};
2830
import {hasRole} from './DOMAccessibilityRoles';
2931
import {
3032
createElement,
@@ -484,29 +486,45 @@ export function appendChildToContainer(
484486
container: Container,
485487
child: Instance | TextInstance,
486488
): void {
487-
let parentNode;
488-
if (container.nodeType === COMMENT_NODE) {
489-
parentNode = (container.parentNode: any);
490-
parentNode.insertBefore(child, container);
491-
} else {
492-
parentNode = container;
493-
parentNode.appendChild(child);
494-
}
495-
// This container might be used for a portal.
496-
// If something inside a portal is clicked, that click should bubble
497-
// through the React tree. However, on Mobile Safari the click would
498-
// never bubble through the *DOM* tree unless an ancestor with onclick
499-
// event exists. So we wouldn't see it and dispatch it.
500-
// This is why we ensure that non React root containers have inline onclick
501-
// defined.
502-
// https://github.com/facebook/react/issues/11918
503-
const reactRootContainer = container._reactRootContainer;
504-
if (
505-
(reactRootContainer === null || reactRootContainer === undefined) &&
506-
parentNode.onclick === null
507-
) {
508-
// TODO: This cast may not be sound for SVG, MathML or custom elements.
509-
trapClickOnNonInteractiveElement(((parentNode: any): HTMLElement));
489+
switch (container.nodeType) {
490+
case DOCUMENT_NODE: {
491+
const documentElement = container.documentElement;
492+
// We cannot append an html element to the document when another one is present
493+
// We also cannot remove the html element without breaking some browsers. Instead
494+
// we are going to swap the current node with the document root element and append
495+
// the head and children to this container. It should have already been cleared
496+
// during an earlier step of the commit phase.
497+
498+
replaceNode(child, documentElement);
499+
const childrenToAppend = child.childNodes;
500+
while (childrenToAppend.length) {
501+
documentElement.appendChild(childrenToAppend[0]);
502+
}
503+
return;
504+
}
505+
case COMMENT_NODE: {
506+
container.parentNode.insertBefore(child, container);
507+
return;
508+
}
509+
default: {
510+
container.appendChild(child);
511+
// This container might be used for a portal.
512+
// If something inside a portal is clicked, that click should bubble
513+
// through the React tree. However, on Mobile Safari the click would
514+
// never bubble through the *DOM* tree unless an ancestor with onclick
515+
// event exists. So we wouldn't see it and dispatch it.
516+
// This is why we ensure that non React root containers have inline onclick
517+
// defined.
518+
// https://github.com/facebook/react/issues/11918
519+
const reactRootContainer = container._reactRootContainer;
520+
if (
521+
(reactRootContainer === null || reactRootContainer === undefined) &&
522+
container.onclick === null
523+
) {
524+
// TODO: This cast may not be sound for SVG, MathML or custom elements.
525+
trapClickOnNonInteractiveElement(((container: any): HTMLElement));
526+
}
527+
}
510528
}
511529
}
512530

@@ -573,10 +591,24 @@ export function removeChildFromContainer(
573591
container: Container,
574592
child: Instance | TextInstance | SuspenseInstance,
575593
): void {
576-
if (container.nodeType === COMMENT_NODE) {
577-
(container.parentNode: any).removeChild(child);
578-
} else {
579-
container.removeChild(child);
594+
switch (container.nodeType) {
595+
case DOCUMENT_NODE: {
596+
detachDeletedInstance(child);
597+
let childOfChild = child.firstChild;
598+
while (childOfChild) {
599+
child.removeChild(childOfChild);
600+
childOfChild = child.firstChild;
601+
}
602+
return;
603+
}
604+
case COMMENT_NODE: {
605+
(container.parentNode: any).removeChild(child);
606+
return;
607+
}
608+
default: {
609+
container.removeChild(child);
610+
return;
611+
}
580612
}
581613
}
582614

@@ -672,10 +704,7 @@ export function clearContainer(container: Container): void {
672704
if (container.nodeType === ELEMENT_NODE) {
673705
((container: any): Element).textContent = '';
674706
} else if (container.nodeType === DOCUMENT_NODE) {
675-
const body = ((container: any): Document).body;
676-
if (body != null) {
677-
body.textContent = '';
678-
}
707+
((container: any): Document).documentElement.textContent = '';
679708
}
680709
}
681710

0 commit comments

Comments
 (0)