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([]);
+ },
+ );
});
});