diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 727ec694bb..c2877d4be0 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -3093,7 +3093,7 @@ function normalizeListenerOptions( return `c=${opts ? '1' : '0'}`; } - return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; + return `c=${opts.capture ? '1' : '0'}`; } function indexOfEventListener( eventListeners: Array, diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 3b702648ef..07baa34779 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -814,6 +814,86 @@ describe('FragmentRefs', () => { expect(logs).toEqual([]); }); + // @gate enableFragmentRefs + it( + 'removes a capture listener registered with boolean when removed with options object', + async () => { + const fragmentRef = React.createRef(null); + function Test() { + return ( + +
+ + ); + } + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + const logs = []; + function logCapture() { + logs.push('capture'); + } + + // Register with boolean `true` (capture phase) + fragmentRef.current.addEventListener('click', logCapture, true); + document.querySelector('#child-a').click(); + expect(logs).toEqual(['capture']); + + logs.length = 0; + + // Remove with equivalent options object {capture: true} + // Per DOM spec, these are identical - the listener MUST be removed + fragmentRef.current.removeEventListener('click', logCapture, { + capture: true, + }); + document.querySelector('#child-a').click(); + // Listener should have been removed - logs must remain empty + expect(logs).toEqual([]); + }, + ); + + // @gate enableFragmentRefs + it( + 'removes a capture listener registered with options object when removed with boolean', + async () => { + const fragmentRef = React.createRef(null); + function Test() { + return ( + +
+ + ); + } + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + const logs = []; + function logCapture() { + logs.push('capture'); + } + + // Register with options object {capture: true} + fragmentRef.current.addEventListener('click', logCapture, { + capture: true, + }); + document.querySelector('#child-b').click(); + expect(logs).toEqual(['capture']); + + logs.length = 0; + + // Remove with boolean `true` + // Per DOM spec, these are identical - the listener MUST be removed + fragmentRef.current.removeEventListener('click', logCapture, true); + document.querySelector('#child-b').click(); + // Listener should have been removed - logs must remain empty + expect(logs).toEqual([]); + }, + ); + // @gate enableFragmentRefs it('applies event listeners to portaled children', async () => { const fragmentRef = React.createRef(); @@ -2680,5 +2760,49 @@ describe('FragmentRefs', () => { window.scrollTo = originalScrollTo; restoreRange(); }); + + // @gate enableFragmentRefs + it('does not deduplicate listeners with mismatched passive option', async () => { + const fragmentRef = React.createRef(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + +
+ , + ); + }); + + const logs = []; + const handler = () => logs.push('fired'); + + // Register with passive: false + fragmentRef.current.addEventListener('click', handler, {passive: false}); + // Register with passive: true - DOM spec: these are DIFFERENT listeners + fragmentRef.current.addEventListener('click', handler, {passive: true}); + + document.querySelector('#child').click(); + // Both should fire because passive:false and passive:true are distinct + expect(logs).toEqual(['fired', 'fired']); + + // Remove with passive: false only removes the passive:false registration + fragmentRef.current.removeEventListener('click', handler, {passive: false}); + + logs.length = 0; + document.querySelector('#child').click(); + // passive:true listener should still fire + expect(logs).toEqual(['fired']); + + fragmentRef.current.removeEventListener('click', handler, {passive: true}); + + logs.length = 0; + document.querySelector('#child').click(); + expect(logs).toEqual([]); + + document.body.removeChild(container); + }); }); });