Skip to content

[Fizz] Don't discard a queued dehydrated boundary on a concurrent update#37016

Open
Saransh-Jainbu wants to merge 1 commit into
react:mainfrom
Saransh-Jainbu:fizz-discard-queued-dehydrated-boundary
Open

[Fizz] Don't discard a queued dehydrated boundary on a concurrent update#37016
Saransh-Jainbu wants to merge 1 commit into
react:mainfrom
Saransh-Jainbu:fizz-discard-queued-dehydrated-boundary

Conversation

@Saransh-Jainbu

@Saransh-Jainbu Saransh-Jainbu commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes path 2 of #37014 — the transient DOM duplication where streamed Suspense content briefly exists twice.

When a concurrent client update (didReceiveUpdate) reaches a dehydrated Suspense boundary, updateDehydratedSuspenseComponent fell through to retrySuspenseComponentWithoutHydrating for both the still-pending ($?) and already-queued-for-reveal ($~) states.

For the $~ case the completed content has already streamed in and been queued for a throttled/rAF reveal (React 19.2 batched reveals). Discarding it to client-render orphans that streamed segment and races the in-flight reveal, so the client-rendered tree and the still-unrevealed streamed segment coexist in the DOM until the queued reveal runs and cleans up — exactly the ~300ms duplication window the issue describes.

This is the second trigger path called out in the issue thread (a concurrent app-level update racing hydration), distinct from the document-load timing of path 1. It corresponds to the long-standing TODO in updateDehydratedSuspenseComponent:

// TODO: The Fizz runtime might still stream in completed HTML, out-of-band. Should we fix this? There's a version of this bug that happens during client rendering, too. Needs more consideration.

Fix

Split the $~ case off first and give it the same treatment the existing "still pending" branch already uses: keep the dehydrated child, mark DidCapture | Callback, and bail. The Callback flag re-registers the _reactRetry callback that the Fizz reveal invokes, so hydration resumes cleanly once the reveal swaps the content into place. The $? path is unchanged.

Supporting plumbing: a new isSuspenseInstanceQueued host-config method, threaded through the DOM config, both reconciler forks (custom/noop), and both no-hydration shims.

Behavior (verified by instrumenting the DOM)

Window Before After
After concurrent update $~ marker gone — boundary discarded, streamed <span> orphaned $~ intact, streamed content preserved
After reveal streamed content deleted (0 spans) revealed in place (1 span)

Test

Adds a regression test in ReactDOMFizzServer-test.js that reproduces the race with:

  • Independent server/client promises — a shared promise would let the client "shortcut" straight to a successful hydration via React's internal ping/retry and never exercise the race (this is the false-negative trap noted in the issue discussion).
  • requestAnimationFrame interception — holds the queued reveal captive so the concurrent update lands while the boundary is genuinely $~.

The test fails without the reconciler change and passes with it.

Caveat

This is a rigorous React-only reproduction of the mechanism. I was not able to reproduce the reporter's exact Next.js App Router end-to-end scenario locally (that needs the Next runtime), but the reconciler-level race and its fix are demonstrated deterministically.

Test plan

  • yarn test ReactDOMFizzServer — 168/168 pass (new test included)
  • yarn test ReactDOMServerPartialHydration — 65/65 pass (no regression to $? behavior)
  • yarn linc clean, yarn prettier applied, Flow reports no errors

@meta-cla meta-cla Bot added the CLA Signed label Jul 15, 2026
@react-sizebot

react-sizebot commented Jul 15, 2026

Copy link
Copy Markdown

Comparing: 7023f50...383569b

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB = 1.91 kB 1.91 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 614.35 kB 614.11 kB +0.08% 108.56 kB 108.64 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB +0.05% 1.91 kB 1.91 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 685.53 kB 685.30 kB +0.07% 120.04 kB 120.13 kB
facebook-www/ReactDOM-prod.classic.js = 705.88 kB 705.66 kB +0.06% 123.62 kB 123.70 kB
facebook-www/ReactDOM-prod.modern.js = 696.20 kB 695.98 kB +0.09% 122.02 kB 122.13 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-dom/cjs/react-dom-profiling.development.js = 1,284.60 kB 1,281.00 kB = 213.86 kB 213.65 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js = 1,284.20 kB 1,280.60 kB = 214.59 kB 214.39 kB
oss-experimental/react-dom/cjs/react-dom-client.development.js = 1,267.65 kB 1,264.05 kB = 210.98 kB 210.78 kB
oss-stable/react-dom/cjs/react-dom-profiling.development.js = 1,188.42 kB 1,184.94 kB = 199.07 kB 198.94 kB
oss-stable-semver/react-dom/cjs/react-dom-profiling.development.js = 1,188.30 kB 1,184.82 kB = 199.04 kB 198.91 kB
oss-stable/react-dom/cjs/react-dom-client.development.js = 1,171.41 kB 1,167.93 kB = 196.15 kB 196.01 kB
oss-stable-semver/react-dom/cjs/react-dom-client.development.js = 1,171.28 kB 1,167.80 kB = 196.12 kB 195.99 kB
facebook-www/ReactDOMTesting-dev.classic.js = 1,317.11 kB 1,313.10 kB = 219.54 kB 219.30 kB
facebook-www/ReactDOMTesting-dev.modern.js = 1,307.89 kB 1,303.89 kB = 217.71 kB 217.49 kB
facebook-www/ReactDOM-dev.classic.js = 1,300.57 kB 1,296.56 kB = 215.75 kB 215.51 kB
facebook-www/ReactDOM-dev.modern.js = 1,291.36 kB 1,287.35 kB = 213.90 kB 213.68 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-dev.js = 1,246.36 kB 1,242.23 kB = 207.75 kB 207.57 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-dev.js = 1,229.46 kB 1,225.33 kB = 204.88 kB 204.70 kB

Generated by 🚫 dangerJS against 383569b

When a concurrent client update (didReceiveUpdate) reaches a dehydrated
Suspense boundary, updateDehydratedSuspenseComponent fell through to
retrySuspenseComponentWithoutHydrating for both the still-pending ($?)
and already-queued-for-reveal ($~) states.

For the $~ case the completed content has already streamed in and been
queued for a throttled/rAF reveal. Discarding it to client-render orphans
that streamed segment and races the in-flight reveal, so both the
client-rendered tree and the still-unrevealed streamed segment coexist in
the DOM until the queued reveal runs and cleans up. This is the transient
DOM duplication reported in react#37014 (path 2: a concurrent
app-level update racing hydration, as opposed to the document-load timing
of path 1).

Split the $~ case off first and give it the same treatment the existing
"still pending" branch already uses: keep the dehydrated child, mark
DidCapture | Callback, and bail. The Callback flag re-registers the
_reactRetry callback that the Fizz reveal invokes, so hydration resumes
cleanly once the reveal swaps the content into place. The $? path is
unchanged.

Adds isSuspenseInstanceQueued to the host config (DOM config, both
reconciler forks, and the no-hydration shims) and a regression test in
ReactDOMFizzServer that reproduces the race with independent server/client
promises and requestAnimationFrame interception. The test fails without
the reconciler change and passes with it.
@Saransh-Jainbu
Saransh-Jainbu force-pushed the fizz-discard-queued-dehydrated-boundary branch from 95c630d to 383569b Compare July 15, 2026 06:18
@eps1lon
eps1lon self-requested a review July 15, 2026 09:46

@daltino daltino left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces a fix to ensure a queued dehydrated boundary is not discarded during concurrent updates, addressing a race condition in Fizz hydration. The changes are well-focused, adding a new isSuspenseInstanceQueued utility and incorporating it into the reconciliation logic. The tests provide ample coverage for the edge case, adhering to the contribution guidelines.

@Saransh-Jainbu

Copy link
Copy Markdown
Contributor Author

@eps1lon could you take a look when you get a chance?

Quick summary of what this fixes and what I verified:

  • Path 2 of Transient DOM duplication window (~300 ms) when batched Suspense reveal races hydration-time client render of dehydrated boundaries (React 19.2) #37014: a concurrent client update was unconditionally discarding a dehydrated boundary even when it was already `$~` (queued for reveal, content already streamed) — racing the reveal and causing the transient duplication.
  • Fix: treat `$~` like the existing "still pending" case (bail, keep dehydrated content) instead of falling through to `retrySuspenseComponentWithoutHydrating`.
  • Verified two ways: (1) a deterministic unit test that fails without the change and passes with it, and (2) manual testing against a real Next.js App Router production build (patched React linked in), where the race was reproduced live in a browser with no duplication/errors.

Thanks in advance!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants