From ce7699a1418c9f391e483800fd733dd79fd9b536 Mon Sep 17 00:00:00 2001 From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:06:55 +0530 Subject: [PATCH 1/5] Add parameters to options string iFix FragmentInstance listener key mismatch between boolean and object capture optionsn ReactFiberConfigDOM --- packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 727ec694bbf7..18e56edf103a 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -3090,7 +3090,7 @@ function normalizeListenerOptions( } if (typeof opts === 'boolean') { - return `c=${opts ? '1' : '0'}`; + return `c=${opts ? '1' : '0'}&o=0&p=0`; } return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; From c380e05cb73c13f329e380bf9af01d5e4dd74614 Mon Sep 17 00:00:00 2001 From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:09:30 +0530 Subject: [PATCH 2/5] Add tests for removing capture listAdd tests for capture option normalization in FragmentInstance addEventListener/removeEventListenereners in FragmentRefs --- .../__tests__/ReactDOMFragmentRefs-test.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 3b702648eff6..e86e90adf00b 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(); From fcefce9b31d44be0a2dd5b6b072ff2a4f1cd4377 Mon Sep 17 00:00:00 2001 From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:34:44 +0530 Subject: [PATCH 3/5] fix: key listener identity on capture only, drop passive/once from key per DOM spec Simplified return statement for boolean opts by removing unused parameters.Per eps1lon's review: the DOM Living Standard specifies that removeEventListener matches listeners using only (type, callback, capture). The `passive` and `once` options do NOT affect listener identity and must be ignored during removal. The previous fix added `&o=0&p=0` to the boolean branch to match the object branch, but this was wrong in both directions: - Adding passive/once to the key means removeEventListener({passive:true}) would fail to remove a listener added with ({passive:false}), violating the spec. Correct fix: key ONLY on capture in both branches: boolean: `c=${opts ? '1' : '0'}` object: `c=${opts.capture ? '1' : '0'}` Ref: https://dom.spec.whatwg.org/#dom-eventtarget-removeeventlistener Ref: MDN - "Only the capture setting matters to removeEventListener" --- packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 18e56edf103a..c2877d4be00f 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -3090,10 +3090,10 @@ function normalizeListenerOptions( } if (typeof opts === 'boolean') { - return `c=${opts ? '1' : '0'}&o=0&p=0`; + 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, From 47cd78d3eed12e9aba0abb1ab31d1576673813c6 Mon Sep 17 00:00:00 2001 From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:37:26 +0530 Subject: [PATCH 4/5] test: add passive option mismatch test for FragmentRef addEventListener --- .../__tests__/ReactDOMFragmentRefs-test.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index e86e90adf00b..07baa347793a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -2760,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); + }); }); }); From db9b32192ef912506831d6e48e46d4f32b300a9a Mon Sep 17 00:00:00 2001 From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:51:34 +0530 Subject: [PATCH 5/5] fix: correct passive test - per DOM spec passive is not part of listener identity --- .../__tests__/ReactDOMFragmentRefs-test.js | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 07baa347793a..1705422c2bef 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -2762,47 +2762,42 @@ describe('FragmentRefs', () => { }); // @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}); + it( + 'treats passive:true and passive:false as same listener per DOM spec', + async () => { + const fragmentRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); - document.querySelector('#child').click(); - // Both should fire because passive:false and passive:true are distinct - expect(logs).toEqual(['fired', 'fired']); + await act(() => { + root.render( + +
+ , + ); + }); - // Remove with passive: false only removes the passive:false registration - fragmentRef.current.removeEventListener('click', handler, {passive: false}); + const logs = []; + const handler = () => logs.push('fired'); - logs.length = 0; - document.querySelector('#child').click(); - // passive:true listener should still fire - expect(logs).toEqual(['fired']); + // Per DOM spec, listener identity is (type, callback, capture). + // passive is NOT part of the key, so these are the SAME listener. + fragmentRef.current.addEventListener('click', handler, {passive: false}); + // Second add is a no-op (same listener already registered) + fragmentRef.current.addEventListener('click', handler, {passive: true}); - fragmentRef.current.removeEventListener('click', handler, {passive: true}); + document.querySelector('#child').click(); + // Only one invocation because it is the same listener + expect(logs).toEqual(['fired']); - logs.length = 0; - document.querySelector('#child').click(); - expect(logs).toEqual([]); + // removeEventListener also ignores passive when matching + fragmentRef.current.removeEventListener('click', handler, { + passive: true, + }); - document.body.removeChild(container); - }); + logs.length = 0; + document.querySelector('#child').click(); + expect(logs).toEqual([]); + }, + ); }); });