Conversation
… 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
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #36985.
In react-dom 19.2.0-19.2.7,
renderToPipeableStream/renderToReadableStreamcan emit the Suspense boundary completion instruction ($RC(...)) twice for the same boundary when:byteSize > 500), andfallbackitself suspends (e.g. an async component as the fallback), andBy design, when a boundary is eligible for outlining, its still-pending fallback task is not aborted when the content completes (
finishedTaskskipsfallbackAbortableTasks.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 placeholderSegmentin the parent tree (the sameSegmentwhose.boundaryfield points back at theSuspenseBoundary).flushSegmentreadssegment.boundaryto decide whether aSegmentstill represents a boundary that needs to be revealed, but it never cleared that reference after visiting it once. So when the fallback later finishes,finishedSegmentre-queues that same placeholderSegment, and when it's flushed again viaflushPartiallyCompletedSegment→flushSegmentContentwise→flushSegment, 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$RSfor 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.boundaryinflushSegmentonce 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 sameSegmentobject (e.g. triggered by a late-resolving fallback) falls through toflushSubtreeand is treated as ordinary content instead of re-triggering a boundary reveal — this is exactly today's behavior onmain, which does not exhibit this bug. Also reverts theSegment.boundaryfield to being non-read-only (Flow+boundary→boundary) so the reset compiles under Flow, again matchingmain.This is a minimal, targeted backport scoped to the
19.2.xmaintenance branch; it does not touch theisEligibleForOutliningsemantics or any other Fizz behavior.Test plan
Added a regression test to
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.jsthat:<Suspense>boundaries where the inner boundary's fallback itself suspends on an async component,$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:
releases/19.2.xbefore this change (3$RCcalls) and passes after (2$RCcalls, one per boundary).yarn test ReactDOMFizzServer-test.js(169 tests),ReactDOMFizzServerNode-test.js,ReactDOMFizzServerBrowser-test.js, andReactDOMFizzServerEdge-test.jsall pass (208 tests total) with this change.react-dom/serverbundle from this branch: the duplicate$RCcalls disappear after this change.Reported by @polemitis in #36985, with initial investigation by @thisisankit01.