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
client2
',
);
});
+
+ 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(