From ea3ae19293287aace3f85839d7bcdf8d4956a8de Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 9 Jan 2025 13:25:19 -0800 Subject: [PATCH] [Fiber] Support Suspense boundaries anywhere (excluding hydration) This is a follow up to https://github.com/facebook/react/pull/32069 In the prior change I updated Fizz to allow you to render Suspense boundaries at any level within a react-dom application by treating the document body as the default render scope. This change updates Fiber to provide similar semantics. Note that this update still does not deliver hydration so unifying the Fizz and Fiber implementations in a single App is not possible yet. The implementation required a rework of the getHostSibling and getHostParent algorithms. Now most HostSingletons are invisible from a host positioning perspective. Head is special in that it is a valid host scope so when you have Placements inside of it, it will act as the parent. But body, and html, will not directly participate in host positioning. Additionally to support flipping to a fallback , , and tag in a Suspense fallback I updated the offscreen hiding/unhide logic to pierce through singletons when lookin for matching hidable nod boundaries anywhere (excluding hydration) --- .../src/client/ReactFiberConfigDOM.js | 135 ++++--- .../react-dom/src/__tests__/ReactDOM-test.js | 364 ++++++++++++++++++ .../src/__tests__/ReactDOMFloat-test.js | 37 +- .../src/ReactFiberBeginWork.js | 21 +- .../src/ReactFiberCommitHostEffects.js | 114 ++++-- .../src/ReactFiberCommitWork.js | 65 +++- .../src/ReactFiberConfigWithNoSingletons.js | 1 - .../react-reconciler/src/ReactFiberFlags.js | 17 +- .../src/forks/ReactFiberConfig.custom.js | 1 - 9 files changed, 594 insertions(+), 161 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index cea03b73b59d5a..042d6897c6aae1 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 92571ad69e10ca..70a87c777298c8 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 5f50ceb207aba3..bdd50c9d4f4694 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