[Fiber] Detach Fragment refs during the mutation phase (#37326)
Co-authored-by: Claude Code (Opus 5) <noreply@anthropic.com>
Sebastian "Sebbie" Silbermann committed
Aug 19, 2026 at 19:50 UTC
eafeac097ba51e1eab809c07102126bd5f8e5425
2 files changed
+155
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+150
@@ -17,12 +17,14 @@ let act;
17
let container;
18
let Fragment;
19
let Activity;
20
+let Scheduler;
21
let mockIntersectionObserver;
22
let simulateIntersection;
23
let setClientRects;
24
let mockRangeClientRects;
25
let assertConsoleErrorDev;
26
let assertConsoleWarnDev;
27
+let assertLog;
28
29
function Wrapper({children}) {
30
return children;
@@ -38,6 +40,7 @@ describe('FragmentRefs', () => {
40
ReactDOM = require('react-dom');
41
createPortal = ReactDOM.createPortal;
42
act = require('internal-test-utils').act;
43
+ Scheduler = require('scheduler');
44
const IntersectionMocks = require('./utils/IntersectionMocks');
45
mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
46
simulateIntersection = IntersectionMocks.simulateIntersection;
@@ -46,6 +49,7 @@ describe('FragmentRefs', () => {
49
assertConsoleErrorDev =
50
require('internal-test-utils').assertConsoleErrorDev;
51
assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
52
+ assertLog = require('internal-test-utils').assertLog;
53
54
container = document.createElement('div');
55
document.body.innerHTML = '';
@@ -153,6 +157,152 @@ describe('FragmentRefs', () => {
157
expect(childD.reactFragments.has(fragmentParentRef.current)).toBe(true);
158
});
159
160
+ // @gate enableFragmentRefs
161
+ it('runs the ref cleanup when an inline ref callback changes identity', async () => {
162
+ const fragmentInstances = [];
163
+ let rerender;
164
+
165
+ function Test() {
166
+ const [step, setStep] = React.useState(0);
167
+ rerender = () => {
168
+ setStep(p => p + 1);
169
+ };
170
+
171
+ return (
172
+ <Fragment
173
+ ref={fragmentInstance => {
174
+ fragmentInstances.push(fragmentInstance);
175
+ Scheduler.log(`fragment attach ${step}`);
176
+ return () => {
177
+ Scheduler.log(`fragment cleanup ${step}`);
178
+ };
179
+ }}>
180
+ <div
181
+ id="child"
182
+ ref={() => {
183
+ Scheduler.log(`host attach ${step}`);
184
+ return () => {
185
+ Scheduler.log(`host cleanup ${step}`);
186
+ };
187
+ }}
188
+ />
189
+ </Fragment>
190
+ );
191
+ }
192
+
193
+ const root = ReactDOMClient.createRoot(container);
194
+ await act(() => root.render(<Test />));
195
+ assertLog(['fragment attach 0', 'host attach 0']);
196
+
197
+ await act(rerender);
198
+ // Both refs are inlined, so both change identity and are detached before
199
+ // being re-attached. The Fragment detaches ahead of its children, which is
200
+ // the same order it uses when the Fragment itself is deleted.
201
+ assertLog([
202
+ 'fragment cleanup 0',
203
+ 'host cleanup 0',
204
+ 'fragment attach 1',
205
+ 'host attach 1',
206
+ ]);
207
+
208
+ await act(() => root.render(null));
209
+ // The cleanups created by the final render run on unmount.
210
+ assertLog(['fragment cleanup 1', 'host cleanup 1']);
211
+
212
+ // The same FragmentInstance is handed to every attach, so a callback that
213
+ // registers event listeners or observers on it can rely on its cleanup to
214
+ // unregister them again.
215
+ expect(fragmentInstances).toHaveLength(2);
216
+ expect(fragmentInstances[0]).toBe(fragmentInstances[1]);
217
+ });
218
+
219
+ // @gate enableFragmentRefs
220
+ it('runs the ref cleanup when the ref is removed from a mounted Fragment', async () => {
221
+ function Test({withRef}) {
222
+ return (
223
+ <Fragment
224
+ ref={
225
+ withRef
226
+ ? () => {
227
+ Scheduler.log('attach');
228
+ return () => {
229
+ Scheduler.log('cleanup');
230
+ };
231
+ }
232
+ : null
233
+ }>
234
+ <div id="child" />
235
+ </Fragment>
236
+ );
237
+ }
238
+
239
+ const root = ReactDOMClient.createRoot(container);
240
+ await act(() => root.render(<Test withRef={true} />));
241
+ assertLog(['attach']);
242
+
243
+ // The Fragment stays mounted and only the ref goes away. commitAttachRef
244
+ // bails out on a null ref, so the detach is the only thing that can run the
245
+ // cleanup here.
246
+ await act(() => root.render(<Test withRef={false} />));
247
+ assertLog(['cleanup']);
248
+
249
+ // Nothing is left to clean up by the time the Fragment is deleted.
250
+ await act(() => root.render(null));
251
+ assertLog([]);
252
+ });
253
+
254
+ // @gate enableFragmentRefs
255
+ it('detaches and reattaches Fragment refs when StrictMode double invokes', async () => {
256
+ // This one collects its own log rather than using Scheduler.log, because
257
+ // setIsStrictModeForDevtools disables yield values for the duration of the
258
+ // double invoke to keep StrictMode tests quiet, which would hide the very
259
+ // detach and reattach this test is here to observe.
260
+ const logs = [];
261
+ let rerender;
262
+
263
+ function Test() {
264
+ const [step, setStep] = React.useState(0);
265
+ rerender = () => {
266
+ setStep(p => p + 1);
267
+ };
268
+
269
+ return (
270
+ <Fragment
271
+ ref={() => {
272
+ logs.push(`attach ${step}`);
273
+ return () => {
274
+ logs.push(`cleanup ${step}`);
275
+ };
276
+ }}>
277
+ <div id="child" />
278
+ </Fragment>
279
+ );
280
+ }
281
+
282
+ const root = ReactDOMClient.createRoot(container);
283
+ await act(() =>
284
+ root.render(
285
+ <React.StrictMode>
286
+ <Test />
287
+ </React.StrictMode>,
288
+ ),
289
+ );
290
+ if (__DEV__) {
291
+ // The double invoke goes through disappearLayoutEffects and
292
+ // reappearLayoutEffects rather than through the mutation and layout
293
+ // phases, so it exercises a separate pair of Fragment cases.
294
+ expect(logs).toEqual(['attach 0', 'cleanup 0', 'attach 0']);
295
+ } else {
296
+ expect(logs).toEqual(['attach 0']);
297
+ }
298
+
299
+ // The double invoke only applies to newly mounted fibers, so an update
300
+ // detaches and reattaches once in both environments.
301
+ logs.length = 0;
302
+ await act(rerender);
303
+ expect(logs).toEqual(['cleanup 0', 'attach 1']);
304
+ });
305
+
306
describe('focus methods', () => {
307
describe('focus()', () => {
308
// @gate enableFragmentRefs
packages/react-reconciler/src/ReactFiberCommitWork.js
+5
@@ -2780,6 +2780,11 @@ function commitMutationEffectsOnFiber(
2780
}
2781
case Fragment:
2782
if (enableFragmentRefs) {
2783
+ if (flags & Ref) {
2784
+ if (!offscreenSubtreeWasHidden && current !== null) {
2785
+ safelyDetachRef(current, current.return);
2786
+ }
2787
+ }
2788
if (current && current.stateNode !== null) {
2789
updateFragmentInstanceFiber(finishedWork, current.stateNode);
2790
}