Skip to content

[pull] main from react:main - #621

Merged
pull[bot] merged 1 commit into
code:mainfrom
react:main
Jul 29, 2026
Merged

[pull] main from react:main#621
pull[bot] merged 1 commit into
code:mainfrom
react:main

Conversation

@pull

@pull pull Bot commented Jul 29, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

…tree (#37135)

Closes vercel/next.js#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 vercel/next.js#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, vercel/next.js#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.
@pull pull Bot locked and limited conversation to collaborators Jul 29, 2026
@pull pull Bot added the ⤵️ pull label Jul 29, 2026
@pull
pull Bot merged commit 9a81195 into code:main Jul 29, 2026
13 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant