@samitouri / QOS-React-2 / commits / 98803845b9

[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) {