[Fiber] Run Fragment deletion effects for HostText children (#37168)
There was inconsistent behavior with text nodes retaining event listeners while all other nodes have them removed in deletion effects. This fixes that handling
Jack Pope committed
Aug 12, 2026 at 20:17 UTC
98803845b96fad0a2c04bbe9df16d9a257c20de3
2 files changed
+138
-7
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+114
@@ -852,6 +852,60 @@ describe('FragmentRefs', () => {
852
expect(logs).toEqual(['fragment']);
853
});
854
855
+ // @gate enableFragmentRefs && enableFragmentRefsTextNodes
856
+ it('removes event listeners from a deleted text child', async () => {
857
+ const fragmentRef = React.createRef();
858
+ const parentRef = React.createRef();
859
+ const root = ReactDOMClient.createRoot(container);
860
+ let hideText;
861
+
862
+ function Component() {
863
+ const [shouldShowText, setShouldShowText] = React.useState(true);
864
+ hideText = () => {
865
+ setShouldShowText(false);
866
+ };
867
+
868
+ return (
869
+ <div ref={parentRef}>
870
+ <Fragment ref={fragmentRef}>
871
+ {shouldShowText ? 'Hello' : null}
872
+ </Fragment>
873
+ </div>
874
+ );
875
+ }
876
+
877
+ await act(() => {
878
+ root.render(<Component />);
879
+ });
880
+
881
+ const textNode = Array.from(parentRef.current.childNodes).find(
882
+ node => node.nodeType === 3,
883
+ );
884
+ expect(textNode).not.toBe(undefined);
885
+
886
+ const logs = [];
887
+ fragmentRef.current.addEventListener('click', () => {
888
+ logs.push('fragment');
889
+ });
890
+
891
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
892
+ expect(logs).toEqual(['fragment']);
893
+
894
+ await act(() => {
895
+ hideText();
896
+ });
897
+
898
+ const detachedHost = document.createElement('div');
899
+ document.body.appendChild(detachedHost);
900
+ detachedHost.appendChild(textNode);
901
+
902
+ logs.length = 0;
903
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
904
+ expect(logs).toEqual([]);
905
+
906
+ document.body.removeChild(detachedHost);
907
+ });
908
+
909
// @gate enableFragmentRefs
910
it('applies event listeners to host children nested within non-host children', async () => {
911
const fragmentRef = React.createRef();
@@ -1250,6 +1304,66 @@ describe('FragmentRefs', () => {
1304
// Event order is flipped here because the nested child re-registers first
1305
expect(logs).toEqual(['clicked 2', 'clicked 1']);
1306
});
1307
+
1308
+ // @gate enableFragmentRefs && enableFragmentRefsTextNodes
1309
+ it('does not dispatch fragment events from text children while hidden', async () => {
1310
+ const parentRef = React.createRef();
1311
+ const fragmentRef = React.createRef();
1312
+ const root = ReactDOMClient.createRoot(container);
1313
+
1314
+ function Test({mode}) {
1315
+ return (
1316
+ <div ref={parentRef}>
1317
+ <Fragment ref={fragmentRef}>
1318
+ <Activity mode={mode}>
1319
+ <div id="child">Element</div>
1320
+ Text
1321
+ </Activity>
1322
+ </Fragment>
1323
+ </div>
1324
+ );
1325
+ }
1326
+
1327
+ await act(() => {
1328
+ root.render(<Test mode="visible" />);
1329
+ });
1330
+
1331
+ const logs = [];
1332
+ fragmentRef.current.addEventListener('click', e => {
1333
+ logs.push(
1334
+ e.target.nodeType === 3
1335
+ ? 'text'
1336
+ : e.target.id || e.target.tagName,
1337
+ );
1338
+ });
1339
+
1340
+ const textNode = Array.from(parentRef.current.childNodes).find(
1341
+ node => node.nodeType === 3,
1342
+ );
1343
+ expect(textNode).not.toBe(undefined);
1344
+
1345
+ document.getElementById('child').click();
1346
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1347
+ expect(logs).toEqual(['child', 'text']);
1348
+
1349
+ logs.length = 0;
1350
+ await act(() => {
1351
+ root.render(<Test mode="hidden" />);
1352
+ });
1353
+
1354
+ document.getElementById('child').click();
1355
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1356
+ expect(logs).toEqual([]);
1357
+
1358
+ logs.length = 0;
1359
+ await act(() => {
1360
+ root.render(<Test mode="visible" />);
1361
+ });
1362
+
1363
+ document.getElementById('child').click();
1364
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1365
+ expect(logs).toEqual(['child', 'text']);
1366
+ });
1367
});
1368
});
1369
packages/react-reconciler/src/ReactFiberCommitWork.js
+24
-7
@@ -1570,16 +1570,20 @@ function commitDeletionEffectsOnFiber(
1570
if (!offscreenSubtreeWasHidden) {
1571
safelyDetachRef(deletedFiber, nearestMountedAncestor);
1572
}
1573
- if (
1574
- enableFragmentRefs &&
1575
- (deletedFiber.tag === HostComponent ||
1576
- (enableFragmentRefsTextNodes && deletedFiber.tag === HostText))
1577
- ) {
1573
+ if (enableFragmentRefs) {
1574
commitFragmentInstanceDeletionEffects(deletedFiber);
1575
}
1576
// Intentional fallthrough to next branch
1577
}
1578
case HostText: {
1579
+ if (
1580
+ enableFragmentRefs &&
1581
+ enableFragmentRefsTextNodes &&
1582
+ // HostComponent falls through into this case.
1583
+ deletedFiber.tag === HostText
1584
+ ) {
1585
+ commitFragmentInstanceDeletionEffects(deletedFiber);
1586
+ }
1587
// We only need to remove the nearest host child. Set the host parent
1588
// to `null` on the stack to indicate that nested children don't
1589
// need to be removed.
@@ -3152,9 +3156,10 @@ function disappearLayoutEffects(
3156
3157
if (
3158
enableFragmentRefs &&
3159
+ // HostHoistable shares this case via fallthrough but must not be
3160
+ // attributed to fragment instances. HostText has its own case below.
3161
(finishedWork.tag === HostComponent ||
3156
- finishedWork.tag === HostSingleton ||
3157
- (enableFragmentRefsTextNodes && finishedWork.tag === HostText))
3162
+ finishedWork.tag === HostSingleton)
3163
) {
3164
commitFragmentInstanceDeletionEffects(finishedWork);
3165
}
@@ -3165,6 +3170,12 @@ function disappearLayoutEffects(
3170
);
3171
break;
3172
}
3173
+ case HostText: {
3174
+ if (enableFragmentRefs && enableFragmentRefsTextNodes) {
3175
+ commitFragmentInstanceDeletionEffects(finishedWork);
3176
+ }
3177
+ break;
3178
+ }
3179
case HostHoistable: {
3180
// TODO (Offscreen) Check: flags & RefStatic
3181
safelyDetachRef(finishedWork, finishedWork.return);
@@ -3389,6 +3400,12 @@ function reappearLayoutEffects(
3400
safelyAttachRef(finishedWork, finishedWork.return);
3401
break;
3402
}
3403
+ case HostText: {
3404
+ if (enableFragmentRefs && enableFragmentRefsTextNodes) {
3405
+ commitFragmentInstanceInsertionEffects(finishedWork);
3406
+ }
3407
+ break;
3408
+ }
3409
case HostHoistable: {
3410
// $FlowFixMe[constant-condition]
3411
if (supportsResources) {