diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 2572d7043ca..78f5e032b3d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -4300,4 +4300,136 @@ describe('ReactDOMServerPartialHydration', () => { root.unmount(); expect(container.innerHTML).toEqual(''); }); + + it('recovers when an update changes a dehydrated boundary inside a suspended parent boundary', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Sibling() { + if (suspend) { + throw promise; + } + return Sibling; + } + + function App({showSiblingOnMount}) { + const [showSibling, setShowSibling] = React.useState(false); + React.useEffect(() => { + if (showSiblingOnMount) { + // Not a transition: this update reaches the dehydrated inner + // boundary at default priority, before it has hydrated. + setShowSibling(true); + } + }, [showSiblingOnMount]); + return ( +
+ + {showSibling ? : null} + + {showSibling ? 'b' : 'a'} + + +
+ ); + } + + // Don't suspend on the server. + suspend = false; + const finalHTML = ReactDOMServer.renderToString( + , + ); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(container.textContent).toBe('a'); + + // Hydrate. The first effect mounts a suspending sibling in the outer + // boundary (so the outer boundary shows its fallback and its primary + // content is hidden), and at the same time changes the input of the + // inner boundary, which is still dehydrated. + suspend = true; + await act(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + + // The sibling's data arrives. + suspend = false; + await act(async () => { + resolve(); + await promise; + }); + + // The outer boundary should reveal both the sibling and the updated + // inner content. + const sibling = container.querySelector('#sibling'); + const content = container.querySelector('#content'); + expect(sibling).not.toBe(null); + expect(sibling.style.display).not.toBe('none'); + expect(content).not.toBe(null); + expect(content.style.display).not.toBe('none'); + expect(content.textContent).toBe('b'); + }); + + it('recovers when a transition changes a dehydrated boundary inside a suspended parent boundary', async () => { + // Same as the previous test, except the update is wrapped + // in startTransition. + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Sibling() { + if (suspend) { + throw promise; + } + return Sibling; + } + + function App({showSiblingOnMount}) { + const [showSibling, setShowSibling] = React.useState(false); + React.useEffect(() => { + if (showSiblingOnMount) { + React.startTransition(() => { + setShowSibling(true); + }); + } + }, [showSiblingOnMount]); + return ( +
+ + {showSibling ? : null} + + {showSibling ? 'b' : 'a'} + + +
+ ); + } + + suspend = false; + const finalHTML = ReactDOMServer.renderToString( + , + ); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(container.textContent).toBe('a'); + + suspend = true; + await act(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + + suspend = false; + await act(async () => { + resolve(); + await promise; + }); + + const sibling = container.querySelector('#sibling'); + const content = container.querySelector('#content'); + expect(sibling).not.toBe(null); + expect(sibling.style.display).not.toBe('none'); + expect(content).not.toBe(null); + expect(content.style.display).not.toBe('none'); + expect(content.textContent).toBe('b'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js index 0921920cb55..48d650c05e7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js @@ -2976,4 +2976,129 @@ describe('ReactDOMServerPartialHydrationActivity', () => { '
1
client
2
', ); }); + + it('commits new suspending content next to a dehydrated Activity that hides', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Second() { + if (suspend) { + throw promise; + } + return Second; + } + + function App({showSecondOnMount}) { + const [active, setActive] = React.useState('first'); + React.useEffect(() => { + if (showSecondOnMount) { + // Not a transition: this update reaches the dehydrated Activity at + // default priority, before it has hydrated. + setActive('second'); + } + }, [showSecondOnMount]); + return ( +
+ + {active === 'second' ? : null} + + First + + +
+ ); + } + + // Don't suspend on the server. + suspend = false; + const finalHTML = ReactDOMServer.renderToString( + , + ); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(container.textContent).toBe('First'); + + // Hydrate. The first effect mounts new content (still loading) and hides + // the server-rendered Activity while its subtree is still dehydrated. + suspend = true; + await act(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + + // The data for the new row arrives. + suspend = false; + await act(async () => { + resolve(); + await promise; + }); + + // The new row should be visible and the old row hidden. + const second = container.querySelector('#second'); + const first = container.querySelector('#first'); + expect(second).not.toBe(null); + expect(second.style.display).not.toBe('none'); + expect(first === null || first.style.display === 'none').toBe(true); + }); + + it('commits new suspending content next to a dehydrated Activity that hides (transition)', async () => { + // Same as the previous test, except the update is wrapped + // in startTransition. + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Second() { + if (suspend) { + throw promise; + } + return Second; + } + + function App({showSecondOnMount}) { + const [active, setActive] = React.useState('first'); + React.useEffect(() => { + if (showSecondOnMount) { + React.startTransition(() => { + setActive('second'); + }); + } + }, [showSecondOnMount]); + return ( +
+ + {active === 'second' ? : null} + + First + + +
+ ); + } + + suspend = false; + const finalHTML = ReactDOMServer.renderToString( + , + ); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(container.textContent).toBe('First'); + + suspend = true; + await act(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + + suspend = false; + await act(async () => { + resolve(); + await promise; + }); + + const second = container.querySelector('#second'); + const first = container.querySelector('#first'); + expect(second).not.toBe(null); + expect(second.style.display).not.toBe('none'); + expect(first === null || first.style.display === 'none').toBe(true); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 815dc8b9961..29269e32924 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -204,6 +204,7 @@ import { import { pushHiddenContext, reuseHiddenContextOnStack, + isCurrentTreeHidden, } from './ReactFiberHiddenContext'; import {findFirstSuspended} from './ReactFiberSuspenseComponent'; import { @@ -1019,6 +1020,19 @@ function updateDehydratedActivityComponent( if (didReceiveUpdate || hasContextChanged) { // This boundary has changed since the first render. This means that we are now unable to // hydrate it. We might still be able to hydrate it using a higher priority lane. + if (isCurrentTreeHidden()) { + // This boundary is inside a hidden subtree, where all work is + // deferred until the tree is revealed. Selective hydration works by + // rendering the boundary at a higher priority before the update + // applies, so it can't make progress here; delaying the commit to + // wait for it would deadlock. Replacing hidden content isn't + // visible, so give up and client render. + return retryActivityComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); + } const root = getWorkInProgressRoot(); if (root !== null) { const attemptHydrationAtLane = getBumpedLaneForHydration( @@ -3028,6 +3042,19 @@ function updateDehydratedSuspenseComponent( if (didReceiveUpdate || hasContextChanged) { // This boundary has changed since the first render. This means that we are now unable to // hydrate it. We might still be able to hydrate it using a higher priority lane. + if (isCurrentTreeHidden()) { + // This boundary is inside a hidden subtree, where all work is + // deferred until the tree is revealed. Selective hydration works by + // rendering the boundary at a higher priority before the update + // applies, so it can't make progress here; delaying the commit to + // wait for it would deadlock. Replacing hidden content isn't + // visible, so give up and client render. + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); + } const root = getWorkInProgressRoot(); if (root !== null) { const attemptHydrationAtLane = getBumpedLaneForHydration(