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/7] 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/7] 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/7] 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/7] 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/7] 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([]);
+ },
+ );
});
});
From 1116d4ab137affdf669bb0059a8c1a54c9330e67 Mon Sep 17 00:00:00 2001
From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com>
Date: Mon, 23 Mar 2026 21:42:42 +0530
Subject: [PATCH 6/7] test: clarify passive handler comments and add
cross-passive removal test
Clarified comments regarding event listener behavior and added a test case for removing listeners with passive options.
---
.../__tests__/ReactDOMFragmentRefs-test.js | 45 ++++++++++++++++++-
1 file changed, 43 insertions(+), 2 deletions(-)
diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
index 1705422c2bef..fc482d3696db 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -2782,11 +2782,11 @@ describe('FragmentRefs', () => {
// 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)
+ // Second add is a no-op: same (type, callback, capture) identity.
fragmentRef.current.addEventListener('click', handler, {passive: true});
document.querySelector('#child').click();
- // Only one invocation because it is the same listener
+ // First handler fires once (second add was a no-op).
expect(logs).toEqual(['fired']);
// removeEventListener also ignores passive when matching
@@ -2799,5 +2799,46 @@ describe('FragmentRefs', () => {
expect(logs).toEqual([]);
},
);
+ // @gate enableFragmentRefs
+ it(
+ 'removes a listener registered with passive:false when removed with passive:true',
+ async () => {
+ const fragmentRef = React.createRef(null);
+ function Test() {
+ return (
+ <>
+
+ >
+ );
+ }
+ const root = ReactDOMClient.createRoot(container);
+ await act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ const logs = [];
+ function handler() {
+ logs.push('fired');
+ }
+ // Register with passive: false
+ fragmentRef.current.addEventListener('click', handler, {
+ passive: false,
+ });
+ document.querySelector('#child-x').click();
+ expect(logs).toEqual(['fired']);
+ logs.length = 0;
+ // Remove with passive: true - per DOM spec, passive is NOT part of identity
+ // so this MUST remove the listener regardless of passive mismatch.
+ fragmentRef.current.removeEventListener('click', handler, {
+ passive: true,
+ });
+ document.querySelector('#child-x').click();
+ // Listener removed - no more invocations
+ expect(logs).toEqual([]);
+ },
+ );
});
});
From da1e8bbef12e65426874bb8cbcacf0da508226e1 Mon Sep 17 00:00:00 2001
From: Kotha Dhakshin <179742818+Dhakshin2007@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:22:26 +0530
Subject: [PATCH 7/7] test: add jest.spyOn assertion to confirm single listener
registration
---
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
index fc482d3696db..7b41e1a2a876 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -2779,11 +2779,15 @@ describe('FragmentRefs', () => {
const logs = [];
const handler = () => logs.push('fired');
+ const child = document.querySelector('#child');
+ const spy = jest.spyOn(child, 'addEventListener');
// 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 (type, callback, capture) identity.
fragmentRef.current.addEventListener('click', handler, {passive: true});
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith('click', handler, {passive: false});
document.querySelector('#child').click();
// First handler fires once (second add was a no-op).