Skip to content

[Fizz][19.2.x] Fix duplicate Suspense completion instruction when an outlined boundary's fallback resolves late - #37532

Open
sleitor wants to merge 1 commit into
react:releases/19.2.xfrom
sleitor:fix-36985
Open

sleitor wants to merge 1 commit into
react:releases/19.2.xfrom
sleitor:fix-36985

Conversation

@sleitor

@sleitor sleitor commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #36985.

In react-dom 19.2.0-19.2.7, renderToPipeableStream/renderToReadableStream can emit the Suspense boundary completion instruction ($RC(...)) twice for the same boundary when:

  1. the boundary's content is large enough to be eligible for outlining (byteSize > 500), and
  2. the boundary's own fallback itself suspends (e.g. an async component as the fallback), and
  3. the content finishes rendering before the fallback does.

By design, when a boundary is eligible for outlining, its still-pending fallback task is not aborted when the content completes (finishedTask skips fallbackAbortableTasks.forEach(abortTaskSoft, ...) in that case), because the fallback may still be needed if the boundary later gets outlined. The fallback keeps rendering into the boundary's placeholder Segment in the parent tree (the same Segment whose .boundary field points back at the SuspenseBoundary).

flushSegment reads segment.boundary to decide whether a Segment still represents a boundary that needs to be revealed, but it never cleared that reference after visiting it once. So when the fallback later finishes, finishedSegment re-queues that same placeholder Segment, and when it's flushed again via flushPartiallyCompletedSegmentflushSegmentContentwiseflushSegment, the code re-enters the boundary-reveal branch for a boundary that has already been queued/reported as complete — producing either a literal duplicate $RC("B:n","S:n"), or (if the boundary gets a new outlined id on the second pass) a fresh <!--$?--><template id="B:m"> + a second $RC("B:m","S:m"), alongside a now-dangling $RS for the placeholder the first reveal already consumed. On the client this throws while patching DOM the first reveal already replaced, and the boundary recovers via client rendering with recoverable error #419.

Fix

Restore the null-out of segment.boundary in flushSegment once a boundary segment has been visited, so a boundary's placeholder segment is only ever treated as "the boundary" on its first flush. A later reflush of the same Segment object (e.g. triggered by a late-resolving fallback) falls through to flushSubtree and is treated as ordinary content instead of re-triggering a boundary reveal — this is exactly today's behavior on main, which does not exhibit this bug. Also reverts the Segment.boundary field to being non-read-only (Flow +boundaryboundary) so the reset compiles under Flow, again matching main.

This is a minimal, targeted backport scoped to the 19.2.x maintenance branch; it does not touch the isEligibleForOutlining semantics or any other Fizz behavior.

Test plan

Added a regression test to packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js that:

  • renders nested <Suspense> boundaries where the inner boundary's fallback itself suspends on an async component,
  • resolves the (outlining-eligible, >500 byte) content first,
  • resolves the fallback afterwards,
  • and asserts that exactly one $RC(...) completion instruction is emitted for the boundary (previously 3 were emitted — a legitimate duplicate for the outer boundary was expected, but the inner boundary was completed/reflushed under a stale id).

Verified:

  • The new test fails on releases/19.2.x before this change (3 $RC calls) and passes after (2 $RC calls, one per boundary).
  • yarn test ReactDOMFizzServer-test.js (169 tests), ReactDOMFizzServerNode-test.js, ReactDOMFizzServerBrowser-test.js, and ReactDOMFizzServerEdge-test.js all pass (208 tests total) with this change.
  • Also reproduced the original standalone repro script from the issue against an actual built react-dom/server bundle from this branch: the duplicate $RC calls disappear after this change.

Reported by @polemitis in #36985, with initial investigation by @thisisankit01.

… outlined boundary's fallback resolves late

When a Suspense boundary's content is large enough to be eligible for
outlining (byteSize > 500) and completes while its own fallback is
still pending (e.g. the fallback itself suspends on an async
component), the fallback's render task keeps writing into the
boundary's placeholder segment in the parent tree.

flushSegment() reads `segment.boundary` to decide whether a segment
represents a Suspense boundary that still needs to be revealed. It
never cleared this reference after visiting a boundary segment, so
when the fallback later finished and the same placeholder segment was
queued and flushed a second time (via flushPartiallyCompletedSegment),
flushSegment re-entered the boundary-reveal branch for a boundary that
had already been queued/reported as complete. Depending on flush
timing this either emitted a second, duplicate $RC(...) completion
instruction for the same boundary id, or re-declared the boundary
under a new id and emitted $RC for that as well - both of which
reference DOM nodes the client has already consumed, causing recoverable
error react#419 and a client re-render of the boundary.

Restore the null-out of `segment.boundary` once a boundary segment has
been visited by flushSegment, matching the current behavior on main.
This ensures a boundary's placeholder segment is only ever treated as
"the boundary" the first time it is flushed; any later reflush of the
same segment object (e.g. a late-resolving fallback) is treated as
ordinary content instead of re-triggering a boundary reveal.

Fixes react#36985
@meta-cla meta-cla Bot added the CLA Signed label Sep 7, 2026

This branch has not been deployed

No deployments
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.

1 participant