@samitouri / QOS-React-1 / commits / 9a81195bed

[Fiber] Fix hang when updating a dehydrated boundary inside a hidden tree (#37135)

Closes https://github.com/vercel/next.js/issues/95848. Fixes a hang: if an update changes what's inside a server-rendered Suspense or Activity boundary before that boundary has hydrated, and the affected content is hidden, React stops committing. The new content renders once its data arrives, but the render is discarded every time, nothing is scheduled, and nothing ever pings — the update never lands and the app appears frozen. "Hidden" means either of two things, and there's a test for each: - the update itself hides a dehydrated `<Activity>` (while mounting new sibling content that suspends), or - the dehydrated boundary is inside the primary tree of a parent boundary that just suspended and is showing its fallback. This is how we found it in practice. With https://github.com/vercel/next.js/pull/95682, pressing Back before hydration finishes made the router replay the missed navigation from its first effect. This worked fine outside of Cache Components, but in Cache Components mode (which turns on Activity), the old page's Activity (still dehydrated) gets hidden, the new page's content suspends inside the layout's Suspense boundary, and after the data arrives the page stays blank forever. As a result, https://github.com/vercel/next.js/pull/95682 got reverted. If we fix this, we can unrevert it. ## Why it happens When an update changes a dehydrated boundary, we schedule a render at a higher priority to hydrate it before the update applies. If we already tried that, we give up and client render, but mark the render as suspended so it doesn't commit while the hydration attempt might still finish first. Both steps assume the attempt can actually run. Inside a hidden tree it can't, because updates in hidden trees are deferred until the tree is revealed. The scheduled attempt never runs but still consumes the retry lane, which sends every later render into the give-up path — and the give-up path keeps discarding finished renders, waiting for a hydration attempt that isn't in flight. Once the last piece of data resolves there's nothing left to ping us awake. The root ends up with `pendingLanes === suspendedLanes`, `pingedLanes` empty, and no callback scheduled. The update doesn't need to be sync or discrete: a plain setState from an effect is enough. Wrapping the same update in `startTransition` avoids it, which is probably why this went unnoticed. ## The fix If the boundary is inside a hidden tree (`isCurrentTreeHidden()`), skip the hydration attempt and client render right away. There's nothing visible to protect: replacing hidden server HTML doesn't show, and the replacement children render when the tree is revealed. One behavior note: this discards the hidden server HTML instead of preserving it for later hydration on reveal, same as the existing give-up path. Keeping it dehydrated and hydrating at reveal would be a nicer follow-up, but needs commit-phase support that doesn't exist today. ## How did you test this change? The first commit adds failing tests for both boundary types; the fix makes them pass. `startTransition` variants of the same scenarios are included as passing controls. Ran the Activity, partial/selective hydration, Fizz, Suspense, and Offscreen suites in both release channels.

dan committed Jul 28, 2026 at 23:18 UTC 9a81195bed0ded96e10a13f753dce05bee8cc97b
3 files changed +284
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+132
@@ -4300,4 +4300,136 @@ describe('ReactDOMServerPartialHydration', () => {
4300 root.unmount();
4301 expect(container.innerHTML).toEqual('<!--&--><!--/&-->');
4302 });
4303 +
4304 + it('recovers when an update changes a dehydrated boundary inside a suspended parent boundary', async () => {
4305 + let suspend = false;
4306 + let resolve;
4307 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
4308 +
4309 + function Sibling() {
4310 + if (suspend) {
4311 + throw promise;
4312 + }
4313 + return <span id="sibling">Sibling</span>;
4314 + }
4315 +
4316 + function App({showSiblingOnMount}) {
4317 + const [showSibling, setShowSibling] = React.useState(false);
4318 + React.useEffect(() => {
4319 + if (showSiblingOnMount) {
4320 + // Not a transition: this update reaches the dehydrated inner
4321 + // boundary at default priority, before it has hydrated.
4322 + setShowSibling(true);
4323 + }
4324 + }, [showSiblingOnMount]);
4325 + return (
4326 + <div>
4327 + <Suspense fallback={null}>
4328 + {showSibling ? <Sibling /> : null}
4329 + <Suspense fallback={null}>
4330 + <span id="content">{showSibling ? 'b' : 'a'}</span>
4331 + </Suspense>
4332 + </Suspense>
4333 + </div>
4334 + );
4335 + }
4336 +
4337 + // Don't suspend on the server.
4338 + suspend = false;
4339 + const finalHTML = ReactDOMServer.renderToString(
4340 + <App showSiblingOnMount={false} />,
4341 + );
4342 + const container = document.createElement('div');
4343 + container.innerHTML = finalHTML;
4344 + expect(container.textContent).toBe('a');
4345 +
4346 + // Hydrate. The first effect mounts a suspending sibling in the outer
4347 + // boundary (so the outer boundary shows its fallback and its primary
4348 + // content is hidden), and at the same time changes the input of the
4349 + // inner boundary, which is still dehydrated.
4350 + suspend = true;
4351 + await act(() => {
4352 + ReactDOMClient.hydrateRoot(container, <App showSiblingOnMount={true} />);
4353 + });
4354 +
4355 + // The sibling's data arrives.
4356 + suspend = false;
4357 + await act(async () => {
4358 + resolve();
4359 + await promise;
4360 + });
4361 +
4362 + // The outer boundary should reveal both the sibling and the updated
4363 + // inner content.
4364 + const sibling = container.querySelector('#sibling');
4365 + const content = container.querySelector('#content');
4366 + expect(sibling).not.toBe(null);
4367 + expect(sibling.style.display).not.toBe('none');
4368 + expect(content).not.toBe(null);
4369 + expect(content.style.display).not.toBe('none');
4370 + expect(content.textContent).toBe('b');
4371 + });
4372 +
4373 + it('recovers when a transition changes a dehydrated boundary inside a suspended parent boundary', async () => {
4374 + // Same as the previous test, except the update is wrapped
4375 + // in startTransition.
4376 + let suspend = false;
4377 + let resolve;
4378 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
4379 +
4380 + function Sibling() {
4381 + if (suspend) {
4382 + throw promise;
4383 + }
4384 + return <span id="sibling">Sibling</span>;
4385 + }
4386 +
4387 + function App({showSiblingOnMount}) {
4388 + const [showSibling, setShowSibling] = React.useState(false);
4389 + React.useEffect(() => {
4390 + if (showSiblingOnMount) {
4391 + React.startTransition(() => {
4392 + setShowSibling(true);
4393 + });
4394 + }
4395 + }, [showSiblingOnMount]);
4396 + return (
4397 + <div>
4398 + <Suspense fallback={null}>
4399 + {showSibling ? <Sibling /> : null}
4400 + <Suspense fallback={null}>
4401 + <span id="content">{showSibling ? 'b' : 'a'}</span>
4402 + </Suspense>
4403 + </Suspense>
4404 + </div>
4405 + );
4406 + }
4407 +
4408 + suspend = false;
4409 + const finalHTML = ReactDOMServer.renderToString(
4410 + <App showSiblingOnMount={false} />,
4411 + );
4412 + const container = document.createElement('div');
4413 + container.innerHTML = finalHTML;
4414 + expect(container.textContent).toBe('a');
4415 +
4416 + suspend = true;
4417 + await act(() => {
4418 + ReactDOMClient.hydrateRoot(container, <App showSiblingOnMount={true} />);
4419 + });
4420 +
4421 + suspend = false;
4422 + await act(async () => {
4423 + resolve();
4424 + await promise;
4425 + });
4426 +
4427 + const sibling = container.querySelector('#sibling');
4428 + const content = container.querySelector('#content');
4429 + expect(sibling).not.toBe(null);
4430 + expect(sibling.style.display).not.toBe('none');
4431 + expect(content).not.toBe(null);
4432 + expect(content.style.display).not.toBe('none');
4433 + expect(content.textContent).toBe('b');
4434 + });
4435 });
packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js
+125
@@ -2976,4 +2976,129 @@ describe('ReactDOMServerPartialHydrationActivity', () => {
2976 '<div>1</div><span>client</span><div>2</div>',
2977 );
2978 });
2979 +
2980 + it('commits new suspending content next to a dehydrated Activity that hides', async () => {
2981 + let suspend = false;
2982 + let resolve;
2983 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2984 +
2985 + function Second() {
2986 + if (suspend) {
2987 + throw promise;
2988 + }
2989 + return <span id="second">Second</span>;
2990 + }
2991 +
2992 + function App({showSecondOnMount}) {
2993 + const [active, setActive] = React.useState('first');
2994 + React.useEffect(() => {
2995 + if (showSecondOnMount) {
2996 + // Not a transition: this update reaches the dehydrated Activity at
2997 + // default priority, before it has hydrated.
2998 + setActive('second');
2999 + }
3000 + }, [showSecondOnMount]);
3001 + return (
3002 + <div>
3003 + <Suspense fallback={null}>
3004 + {active === 'second' ? <Second /> : null}
3005 + <Activity mode={active === 'first' ? 'visible' : 'hidden'}>
3006 + <span id="first">First</span>
3007 + </Activity>
3008 + </Suspense>
3009 + </div>
3010 + );
3011 + }
3012 +
3013 + // Don't suspend on the server.
3014 + suspend = false;
3015 + const finalHTML = ReactDOMServer.renderToString(
3016 + <App showSecondOnMount={false} />,
3017 + );
3018 + const container = document.createElement('div');
3019 + container.innerHTML = finalHTML;
3020 + expect(container.textContent).toBe('First');
3021 +
3022 + // Hydrate. The first effect mounts new content (still loading) and hides
3023 + // the server-rendered Activity while its subtree is still dehydrated.
3024 + suspend = true;
3025 + await act(() => {
3026 + ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
3027 + });
3028 +
3029 + // The data for the new row arrives.
3030 + suspend = false;
3031 + await act(async () => {
3032 + resolve();
3033 + await promise;
3034 + });
3035 +
3036 + // The new row should be visible and the old row hidden.
3037 + const second = container.querySelector('#second');
3038 + const first = container.querySelector('#first');
3039 + expect(second).not.toBe(null);
3040 + expect(second.style.display).not.toBe('none');
3041 + expect(first === null || first.style.display === 'none').toBe(true);
3042 + });
3043 +
3044 + it('commits new suspending content next to a dehydrated Activity that hides (transition)', async () => {
3045 + // Same as the previous test, except the update is wrapped
3046 + // in startTransition.
3047 + let suspend = false;
3048 + let resolve;
3049 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
3050 +
3051 + function Second() {
3052 + if (suspend) {
3053 + throw promise;
3054 + }
3055 + return <span id="second">Second</span>;
3056 + }
3057 +
3058 + function App({showSecondOnMount}) {
3059 + const [active, setActive] = React.useState('first');
3060 + React.useEffect(() => {
3061 + if (showSecondOnMount) {
3062 + React.startTransition(() => {
3063 + setActive('second');
3064 + });
3065 + }
3066 + }, [showSecondOnMount]);
3067 + return (
3068 + <div>
3069 + <Suspense fallback={null}>
3070 + {active === 'second' ? <Second /> : null}
3071 + <Activity mode={active === 'first' ? 'visible' : 'hidden'}>
3072 + <span id="first">First</span>
3073 + </Activity>
3074 + </Suspense>
3075 + </div>
3076 + );
3077 + }
3078 +
3079 + suspend = false;
3080 + const finalHTML = ReactDOMServer.renderToString(
3081 + <App showSecondOnMount={false} />,
3082 + );
3083 + const container = document.createElement('div');
3084 + container.innerHTML = finalHTML;
3085 + expect(container.textContent).toBe('First');
3086 +
3087 + suspend = true;
3088 + await act(() => {
3089 + ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
3090 + });
3091 +
3092 + suspend = false;
3093 + await act(async () => {
3094 + resolve();
3095 + await promise;
3096 + });
3097 +
3098 + const second = container.querySelector('#second');
3099 + const first = container.querySelector('#first');
3100 + expect(second).not.toBe(null);
3101 + expect(second.style.display).not.toBe('none');
3102 + expect(first === null || first.style.display === 'none').toBe(true);
3103 + });
3104 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+27
@@ -204,6 +204,7 @@ import {
204 import {
205 pushHiddenContext,
206 reuseHiddenContextOnStack,
207 + isCurrentTreeHidden,
208 } from './ReactFiberHiddenContext';
209 import {findFirstSuspended} from './ReactFiberSuspenseComponent';
210 import {
@@ -1019,6 +1020,19 @@ function updateDehydratedActivityComponent(
1020 if (didReceiveUpdate || hasContextChanged) {
1021 // This boundary has changed since the first render. This means that we are now unable to
1022 // hydrate it. We might still be able to hydrate it using a higher priority lane.
1023 + if (isCurrentTreeHidden()) {
1024 + // This boundary is inside a hidden subtree, where all work is
1025 + // deferred until the tree is revealed. Selective hydration works by
1026 + // rendering the boundary at a higher priority before the update
1027 + // applies, so it can't make progress here; delaying the commit to
1028 + // wait for it would deadlock. Replacing hidden content isn't
1029 + // visible, so give up and client render.
1030 + return retryActivityComponentWithoutHydrating(
1031 + current,
1032 + workInProgress,
1033 + renderLanes,
1034 + );
1035 + }
1036 const root = getWorkInProgressRoot();
1037 if (root !== null) {
1038 const attemptHydrationAtLane = getBumpedLaneForHydration(
@@ -3028,6 +3042,19 @@ function updateDehydratedSuspenseComponent(
3042 if (didReceiveUpdate || hasContextChanged) {
3043 // This boundary has changed since the first render. This means that we are now unable to
3044 // hydrate it. We might still be able to hydrate it using a higher priority lane.
3045 + if (isCurrentTreeHidden()) {
3046 + // This boundary is inside a hidden subtree, where all work is
3047 + // deferred until the tree is revealed. Selective hydration works by
3048 + // rendering the boundary at a higher priority before the update
3049 + // applies, so it can't make progress here; delaying the commit to
3050 + // wait for it would deadlock. Replacing hidden content isn't
3051 + // visible, so give up and client render.
3052 + return retrySuspenseComponentWithoutHydrating(
3053 + current,
3054 + workInProgress,
3055 + renderLanes,
3056 + );
3057 + }
3058 const root = getWorkInProgressRoot();
3059 if (root !== null) {
3060 const attemptHydrationAtLane = getBumpedLaneForHydration(