diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index cea03b73b59d5..042d6897c6aae 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -798,24 +798,37 @@ export function appendChildToContainer( container: Container, child: Instance | TextInstance, ): void { - let parentNode; - if (container.nodeType === COMMENT_NODE) { - parentNode = (container.parentNode: any); - if (supportsMoveBefore) { - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. - parentNode.moveBefore(child, container); - } else { - parentNode.insertBefore(child, container); + let parentNode: Document | Element; + switch (container.nodeType) { + case COMMENT_NODE: { + parentNode = (container.parentNode: any); + if (supportsMoveBefore) { + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. + parentNode.moveBefore(child, container); + } else { + parentNode.insertBefore(child, container); + } + return; } - } else { - parentNode = container; - if (supportsMoveBefore) { - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. - parentNode.moveBefore(child, null); - } else { - parentNode.appendChild(child); + case DOCUMENT_NODE: { + parentNode = (container: any).body; + break; } + default: { + if (container.nodeName === 'HTML') { + parentNode = (container.ownerDocument.body: any); + } else { + parentNode = (container: any); + } + } + } + if (supportsMoveBefore) { + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. + parentNode.moveBefore(child, null); + } else { + parentNode.appendChild(child); } + // This container might be used for a portal. // If something inside a portal is clicked, that click should bubble // through the React tree. However, on Mobile Safari the click would @@ -852,21 +865,35 @@ export function insertInContainerBefore( child: Instance | TextInstance, beforeChild: Instance | TextInstance | SuspenseInstance, ): void { - if (container.nodeType === COMMENT_NODE) { - if (supportsMoveBefore) { - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. - (container.parentNode: any).moveBefore(child, beforeChild); - } else { - (container.parentNode: any).insertBefore(child, beforeChild); + let parentNode: Document | Element; + switch (container.nodeType) { + case COMMENT_NODE: { + parentNode = (container.parentNode: any); + break; } - } else { - if (supportsMoveBefore) { - // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. - container.moveBefore(child, beforeChild); - } else { - container.insertBefore(child, beforeChild); + case DOCUMENT_NODE: { + const ownerDocument: Document = (container: any); + parentNode = (ownerDocument.body: any); + break; + } + default: { + if (container.nodeName === 'HTML') { + parentNode = (container.ownerDocument.body: any); + } else { + parentNode = (container: any); + } } } + if (supportsMoveBefore) { + // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. + parentNode.moveBefore(child, beforeChild); + } else { + parentNode.insertBefore(child, beforeChild); + } +} + +export function isSingletonScope(type: string): boolean { + return type === 'head'; } function createEvent(type: DOMEventName, bubbles: boolean): Event { @@ -912,11 +939,22 @@ export function removeChildFromContainer( container: Container, child: Instance | TextInstance | SuspenseInstance, ): void { - if (container.nodeType === COMMENT_NODE) { - (container.parentNode: any).removeChild(child); - } else { - container.removeChild(child); + let parentNode: Document | Element; + switch (container.nodeType) { + case COMMENT_NODE: + parentNode = (container.parentNode: any); + break; + case DOCUMENT_NODE: + parentNode = (container: any).body; + break; + default: + if (container.nodeName === 'HTML') { + parentNode = (container.ownerDocument.body: any); + } else { + parentNode = (container: any); + } } + parentNode.removeChild(child); } export function clearSuspenseBoundary( @@ -964,10 +1002,15 @@ export function clearSuspenseBoundaryFromContainer( ): void { if (container.nodeType === COMMENT_NODE) { clearSuspenseBoundary((container.parentNode: any), suspenseInstance); - } else if (container.nodeType === ELEMENT_NODE) { - clearSuspenseBoundary((container: any), suspenseInstance); + } else if (container.nodeType === DOCUMENT_NODE) { + clearSuspenseBoundary((container: any).body, suspenseInstance); + } else if (container.nodeName === 'HTML') { + clearSuspenseBoundary( + (container.ownerDocument.body: any), + suspenseInstance, + ); } else { - // Document nodes should never contain suspense boundaries. + clearSuspenseBoundary((container: any), suspenseInstance); } // Retry if any event replaying was blocked on this. retryIfBlockedOn(container); @@ -2297,30 +2340,6 @@ export function releaseSingletonInstance(instance: Instance): void { detachDeletedInstance(instance); } -export function clearSingleton(instance: Instance): void { - const element: Element = (instance: any); - let node = element.firstChild; - while (node) { - const nextNode = node.nextSibling; - const nodeName = node.nodeName; - if ( - isMarkedHoistable(node) || - nodeName === 'HEAD' || - nodeName === 'BODY' || - nodeName === 'SCRIPT' || - nodeName === 'STYLE' || - (nodeName === 'LINK' && - ((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') - ) { - // retain these nodes - } else { - element.removeChild(node); - } - node = nextNode; - } - return; -} - // ------------------- // Resources // ------------------- diff --git a/packages/react-dom/src/__tests__/ReactDOM-test.js b/packages/react-dom/src/__tests__/ReactDOM-test.js index 92571ad69e10c..70a87c777298c 100644 --- a/packages/react-dom/src/__tests__/ReactDOM-test.js +++ b/packages/react-dom/src/__tests__/ReactDOM-test.js @@ -566,4 +566,368 @@ describe('ReactDOM', () => { ' in App (at **)', ]); }); + + it('should render root host components into body scope when the container is a Document', async () => { + function App({phase}) { + return ( + <> + {phase < 1 ? null :
..before
} + {phase < 3 ?
before
: null} + {phase < 2 ? null :
before..
} + + + {phase < 1 ? null : } + {phase < 3 ? : null} + {phase < 2 ? null : } + + + {phase < 1 ? null :
..inside
} + {phase < 3 ?
inside
: null} + {phase < 2 ? null :
inside..
} + + + {phase < 1 ? null :
..after
} + {phase < 3 ?
after
: null} + {phase < 2 ? null :
after..
} + + ); + } + + const root = ReactDOMClient.createRoot(document); + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
inside
after
', + ); + + // @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,
cannot be a child of <#document>']); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
..inside
inside
..after
after
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
before..
..inside
inside
inside..
..after
after
after..
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before..
..inside
inside..
..after
after..
', + ); + + await act(() => { + root.unmount(); + }); + expect(document.documentElement.outerHTML).toBe( + '', + ); + }); + + it('should render root host components into body scope when the container is a the tag', async () => { + function App({phase}) { + return ( + <> + {phase < 1 ? null :
..before
} + {phase < 3 ?
before
: null} + {phase < 2 ? null :
before..
} + + {phase < 1 ? null : } + {phase < 3 ? : null} + {phase < 2 ? null : } + + + {phase < 1 ? null :
..inside
} + {phase < 3 ?
inside
: null} + {phase < 2 ? null :
inside..
} + + {phase < 1 ? null :
..after
} + {phase < 3 ?
after
: null} + {phase < 2 ? null :
after..
} + + ); + } + + const root = ReactDOMClient.createRoot(document.documentElement); + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
inside
after
', + ); + + // @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,
cannot be a child of ']); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
..inside
inside
..after
after
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
before..
..inside
inside
inside..
..after
after
after..
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before..
..inside
inside..
..after
after..
', + ); + + await act(() => { + root.unmount(); + }); + expect(document.documentElement.outerHTML).toBe( + '', + ); + }); + + it('should render root host components into body scope when the container is a the tag', async () => { + function App({phase}) { + return ( + <> + {phase < 1 ? null :
..before
} + {phase < 3 ?
before
: null} + {phase < 2 ? null :
before..
} + + {phase < 1 ? null : } + {phase < 3 ? : null} + {phase < 2 ? null : } + + {phase < 1 ? null :
..inside
} + {phase < 3 ?
inside
: null} + {phase < 2 ? null :
inside..
} + {phase < 1 ? null :
..after
} + {phase < 3 ?
after
: null} + {phase < 2 ? null :
after..
} + + ); + } + + const root = ReactDOMClient.createRoot(document.body); + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
inside
after
', + ); + + // @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, cannot be a child of ']); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
..inside
inside
..after
after
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before
before..
..inside
inside
inside..
..after
after
after..
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
..before
before..
..inside
inside..
..after
after..
', + ); + + await act(() => { + root.unmount(); + }); + expect(document.documentElement.outerHTML).toBe( + '', + ); + }); + + it('should render children of into the document head even when the container is inside the document body', async () => { + function App({phase}) { + return ( + <> +
before
+ + {phase < 1 ? null : } + {phase < 3 ? : null} + {phase < 2 ? null : } + +
after
+ + ); + } + + const container = document.createElement('main'); + document.body.append(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
after
', + ); + + // @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, cannot be a child of
']); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
after
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
after
', + ); + + await act(() => { + root.render(); + }); + expect(document.documentElement.outerHTML).toBe( + '
before
after
', + ); + + await act(() => { + root.unmount(); + }); + expect(document.documentElement.outerHTML).toBe( + '
', + ); + }); + + it('can render a Suspense boundary above the tag', async () => { + let resolveA; + const promiseA = new Promise(resolve => { + resolveA = resolve; + }); + let resolveB; + const promiseB = new Promise(resolve => { + resolveB = resolve; + }); + let resolveC; + const promiseC = new Promise(resolve => { + resolveC = resolve; + }); + + function ComponentA() { + return React.use(promiseA); + } + + function ComponentB() { + return React.use(promiseB); + } + + function ComponentC() { + return React.use(promiseC); + } + + function App({phase}) { + let content; + switch (phase) { + case 0: + content = ; + break; + case 1: + content = ; + break; + default: + content = ; + } + return ( + + +
fallback
+ + + }> + + + + + +
{content}
+ + +
+ ); + } + + const root = ReactDOMClient.createRoot(document); + await act(() => { + root.render(); + }); + // The initial render is blocked by promiseA so we see the fallback Document + expect(document.documentElement.outerHTML).toBe( + '
fallback
', + ); + + await act(() => { + resolveA('hello world'); + }); + // When promiseA resolves we see the primary Document + expect(document.documentElement.outerHTML).toBe( + '
hello world
', + ); + + await act(() => { + root.render(); + }); + // When we switch to rendering ComponentB synchronously we have to put the Document back into fallback + // The primary content remains hidden until promiseB resolves + expect(document.documentElement.outerHTML).toBe( + '
hello world
fallback
', + ); + + await act(() => { + resolveB('hello you!'); + }); + // When promiseB resolves we see the new primary content inside the primary Document + // style attributes stick around after being unhidden by the Suspense boundary + expect(document.documentElement.outerHTML).toBe( + '
hello you!
', + ); + + await act(() => { + React.startTransition(() => { + root.render(); + }); + }); + expect(document.documentElement.outerHTML).toBe( + '
hello you!
', + ); + + await act(() => { + resolveC('goodbye!'); + }); + expect(document.documentElement.outerHTML).toBe( + '
goodbye!
', + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 5f50ceb207aba..bdd50c9d4f469 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -31,7 +31,6 @@ let hasErrored = false; let fatalError = undefined; let renderOptions; let waitForAll; -let waitForThrow; let assertLog; let Scheduler; let clientAct; @@ -76,7 +75,6 @@ describe('ReactDOMFloat', () => { const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; - waitForThrow = InternalTestUtils.waitForThrow; assertLog = InternalTestUtils.assertLog; clientAct = InternalTestUtils.act; assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; @@ -507,14 +505,7 @@ describe('ReactDOMFloat', () => { , ); - let aggregateError = await waitForThrow(); - expect(aggregateError.errors.length).toBe(2); - expect(aggregateError.errors[0].message).toContain( - 'Invalid insertion of NOSCRIPT', - ); - expect(aggregateError.errors[1].message).toContain( - 'The node to be removed is not a child of this node', - ); + await waitForAll([]); assertConsoleErrorDev([ [ 'Cannot render