@samitouri / QOS-React-1 / commits / eafeac097b

[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 }