diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 86c1021db65..ab3aaf26209 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -852,6 +852,60 @@ describe('FragmentRefs', () => { expect(logs).toEqual(['fragment']); }); + // @gate enableFragmentRefs && enableFragmentRefsTextNodes + it('removes event listeners from a deleted text child', async () => { + const fragmentRef = React.createRef(); + const parentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + let hideText; + + function Component() { + const [shouldShowText, setShouldShowText] = React.useState(true); + hideText = () => { + setShouldShowText(false); + }; + + return ( +
+ + {shouldShowText ? 'Hello' : null} + +
+ ); + } + + await act(() => { + root.render(); + }); + + const textNode = Array.from(parentRef.current.childNodes).find( + node => node.nodeType === 3, + ); + expect(textNode).not.toBe(undefined); + + const logs = []; + fragmentRef.current.addEventListener('click', () => { + logs.push('fragment'); + }); + + textNode.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(logs).toEqual(['fragment']); + + await act(() => { + hideText(); + }); + + const detachedHost = document.createElement('div'); + document.body.appendChild(detachedHost); + detachedHost.appendChild(textNode); + + logs.length = 0; + textNode.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(logs).toEqual([]); + + document.body.removeChild(detachedHost); + }); + // @gate enableFragmentRefs it('applies event listeners to host children nested within non-host children', async () => { const fragmentRef = React.createRef(); @@ -1250,6 +1304,66 @@ describe('FragmentRefs', () => { // Event order is flipped here because the nested child re-registers first expect(logs).toEqual(['clicked 2', 'clicked 1']); }); + + // @gate enableFragmentRefs && enableFragmentRefsTextNodes + it('does not dispatch fragment events from text children while hidden', async () => { + const parentRef = React.createRef(); + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + + function Test({mode}) { + return ( +
+ + +
Element
+ Text +
+
+
+ ); + } + + await act(() => { + root.render(); + }); + + const logs = []; + fragmentRef.current.addEventListener('click', e => { + logs.push( + e.target.nodeType === 3 + ? 'text' + : e.target.id || e.target.tagName, + ); + }); + + const textNode = Array.from(parentRef.current.childNodes).find( + node => node.nodeType === 3, + ); + expect(textNode).not.toBe(undefined); + + document.getElementById('child').click(); + textNode.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(logs).toEqual(['child', 'text']); + + logs.length = 0; + await act(() => { + root.render(); + }); + + document.getElementById('child').click(); + textNode.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(logs).toEqual([]); + + logs.length = 0; + await act(() => { + root.render(); + }); + + document.getElementById('child').click(); + textNode.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(logs).toEqual(['child', 'text']); + }); }); }); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 415fecf8713..3ccba8a847a 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -1570,16 +1570,20 @@ function commitDeletionEffectsOnFiber( if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); } - if ( - enableFragmentRefs && - (deletedFiber.tag === HostComponent || - (enableFragmentRefsTextNodes && deletedFiber.tag === HostText)) - ) { + if (enableFragmentRefs) { commitFragmentInstanceDeletionEffects(deletedFiber); } // Intentional fallthrough to next branch } case HostText: { + if ( + enableFragmentRefs && + enableFragmentRefsTextNodes && + // HostComponent falls through into this case. + deletedFiber.tag === HostText + ) { + commitFragmentInstanceDeletionEffects(deletedFiber); + } // We only need to remove the nearest host child. Set the host parent // to `null` on the stack to indicate that nested children don't // need to be removed. @@ -3152,9 +3156,10 @@ function disappearLayoutEffects( if ( enableFragmentRefs && + // HostHoistable shares this case via fallthrough but must not be + // attributed to fragment instances. HostText has its own case below. (finishedWork.tag === HostComponent || - finishedWork.tag === HostSingleton || - (enableFragmentRefsTextNodes && finishedWork.tag === HostText)) + finishedWork.tag === HostSingleton) ) { commitFragmentInstanceDeletionEffects(finishedWork); } @@ -3165,6 +3170,12 @@ function disappearLayoutEffects( ); break; } + case HostText: { + if (enableFragmentRefs && enableFragmentRefsTextNodes) { + commitFragmentInstanceDeletionEffects(finishedWork); + } + break; + } case HostHoistable: { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); @@ -3389,6 +3400,12 @@ function reappearLayoutEffects( safelyAttachRef(finishedWork, finishedWork.return); break; } + case HostText: { + if (enableFragmentRefs && enableFragmentRefsTextNodes) { + commitFragmentInstanceInsertionEffects(finishedWork); + } + break; + } case HostHoistable: { // $FlowFixMe[constant-condition] if (supportsResources) {