Skip to content

[Fiber] Detach Fragment refs during the mutation phase - #37326

Merged
eps1lon merged 2 commits into
mainfrom
sebbie/fragment-ref-cleanup-rerender-0d3cf7
Aug 19, 2026
Merged

[Fiber] Detach Fragment refs during the mutation phase#37326
eps1lon merged 2 commits into
mainfrom
sebbie/fragment-ref-cleanup-rerender-0d3cf7

Conversation

@eps1lon

@eps1lon eps1lon commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

A ref callback inlined into a component's render gets a new identity on every render, so React marks the Ref flag and re-attaches the ref. For a Fragment that re-attach happened without the matching detach so event listeners could accumulate if the event listener also changed identity.

How did you test this change?

  • added test characterizing the issue in the first commit

@meta-cla meta-cla Bot added the CLA Signed label Aug 19, 2026
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Aug 19, 2026
@eps1lon eps1lon changed the title sebbie/fragment ref cleanup rerender 0d3cf7 [Fiber] Detach Fragment refs during the mutation phase Aug 19, 2026
eps1lon and others added 2 commits August 19, 2026 19:15
A `ref` callback inlined into a component's render gets a new identity on every
render, which is supposed to make React detach the old ref before attaching the
new one. These tests pin down what actually happens for a `Fragment` ref, across
the three ways such a ref gets detached, and their assertions record today's
behavior so the suite stays green. Each place where that behavior is wrong
carries a `TODO` naming the value that should have been there instead.

The first test renders a `Fragment` and a host `<div>` carrying identically
shaped inline ref callbacks in the same tree, so that both travel through a
single commit and any difference between them is attributable to the commit
phase rather than to scheduling. The host ref runs its cleanup before
re-attaching while the Fragment ref re-attaches with nothing in between, so two
attaches produce one cleanup and that one runs on unmount. Every attach receives
the same `FragmentInstance`, which is what turns the dropped cleanup into an
accumulating leak for anything the callback registers on that instance.

The second test removes the ref from a Fragment that stays mounted, which takes
the `ref === null` branch of `markRef` and then bails out of `commitAttachRef`.
Nothing runs at all here, not even on the later deletion, because by then the
ref to detach against is already null, so this cleanup is lost outright rather
than deferred.

The third test wraps the first scenario in `StrictMode`. Its mount assertion
holds today, since the double invoke goes through `disappearLayoutEffects` and
`reappearLayoutEffects`, which pair their Fragment detach and attach correctly.
What it does not do is reach the update, because the double invoke is gated on
newly placed fibers. This one collects its own log rather than using
`Scheduler.log`, because `setIsStrictModeForDevtools` disables yield values for
the duration of the double invoke to keep StrictMode tests quiet, which would
hide the very detach and reattach the test is there to observe.

Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>
`commitMutationEffectsOnFiber` acts on the `Ref` flag for every ref-bearing tag
except `Fragment`, whose case only called `updateFragmentInstanceFiber` before
falling through. `updateFragment` does mark the flag whenever the ref identity
changes and `commitLayoutEffectOnFiber` does act on it by attaching, so a
`Fragment` with an inline ref callback re-attached on every render while the
cleanup returned by the previous callback was overwritten in `commitAttachRef`
without ever being called. Since `createWorkInProgress` copies `refCleanup`
forward the dropped cleanup was unrecoverable, and since the same
`FragmentInstance` survives re-renders, event listeners and observers registered
in the callback accumulated on it.

This change adds the same detach guard the other tags use. It runs before
`updateFragmentInstanceFiber` repoints `_fragmentFiber` at the new fiber so that
a cleanup which walks children observes the tree it was created against, which
matches how host components detach against `current`. It also runs ahead of the
child traversal, the order the Fragment case already uses in
`commitDeletionEffectsOnFiber` and `disappearLayoutEffects`.

The characterization tests added in the previous commit are updated to assert
the corrected behavior. Removing a ref from a mounted Fragment now runs its
cleanup at the point of removal, and the StrictMode test keeps its mount
assertion unchanged, since that path was already correct, and only gains the
cleanup on update.

Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>
@eps1lon
eps1lon force-pushed the sebbie/fragment-ref-cleanup-rerender-0d3cf7 branch from 3e6055f to 0157f6f Compare August 19, 2026 17:16
@eps1lon
eps1lon requested a review from jackpope August 19, 2026 17:22
@eps1lon
eps1lon marked this pull request as ready for review August 19, 2026 17:22
@eps1lon
eps1lon merged commit eafeac0 into main Aug 19, 2026
240 of 241 checks passed
@eps1lon
eps1lon deleted the sebbie/fragment-ref-cleanup-rerender-0d3cf7 branch August 19, 2026 17:51
github-actions Bot pushed a commit that referenced this pull request Aug 19, 2026
Co-authored-by: Claude Code (Opus 5) <noreply@anthropic.com>

DiffTrain build for [eafeac0](eafeac0)
github-actions Bot pushed a commit that referenced this pull request Aug 19, 2026
Co-authored-by: Claude Code (Opus 5) <noreply@anthropic.com>

DiffTrain build for [eafeac0](eafeac0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants