Skip to content

Commit c85c5b8

Browse files
committed
Fix FragmentInstance listener leak: normalize boolean vs object capture options per DOM spec (#36047)
## Summary `FragmentInstance.addEventListener` and `removeEventListener` fail to cross-match listeners when the `capture` option is passed as a **boolean** in one call and an **options object** in the other. This violates the [DOM Living Standard](https://dom.spec.whatwg.org/#dom-eventtarget-removeeventlistener), which states that `addEventListener(type, fn, true)` and `addEventListener(type, fn, {capture: true})` are identical. ### Root Cause In `ReactFiberConfigDOM.js`, the `normalizeListenerOptions` function generates a listener key string for deduplication. The boolean branch generates a **different format** than the object branch: ```js // Boolean branch (old) — produces "c=1" return `c=${opts ? '1' : '0'}`; // Object branch — produces "c=1&o=0&p=0" return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`; ``` Because the keys differ, `indexOfEventListener` cannot match them — so `removeEventListener('click', fn, {capture: true})` silently fails to remove a listener registered with `addEventListener('click', fn, true)`, and vice versa. This causes a **memory leak and event listener accumulation** on all Fragment child DOM nodes. ### Fix Normalize the boolean branch to produce the same full key format: ```js // Boolean branch (fixed) — now produces "c=1&o=0&p=0" (matches object branch) return `c=${opts ? '1' : '0'}&o=0&p=0`; ``` This makes both forms produce an identical key, matching the DOM spec behavior. ### When Was This Introduced This bug has been present since `FragmentInstance` event listener tracking was first added. It became reachable in production as of [#36026](#36026) which enabled `enableFragmentRefs` + `enableFragmentRefsInstanceHandles` across all builds (merged 3 days ago). ### Tests Added two new regression tests to `ReactDOMFragmentRefs-test.js`: 1. `removes a capture listener registered with boolean when removed with options object` 2. `removes a capture listener registered with options object when removed with boolean` Both tests were failing before this fix and pass after. ## How did you test this change? Added two new automated tests covering both cross-form removal directions. Existing tests continue to pass. ## Changelog ### React DOM - **Fixed** `FragmentInstance.removeEventListener()` not removing capture-phase listeners when the `capture` option form (boolean vs options object) differs between `add` and `remove` calls. DiffTrain build for [142cfde](142cfde)
1 parent 02ee372 commit c85c5b8

34 files changed

Lines changed: 96 additions & 146 deletions

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
94643c3b8516928e4cc7fad99912272670a0a990
1+
142cfde89edab3d4eabd6335458b4c8736cebfb6
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
94643c3b8516928e4cc7fad99912272670a0a990
1+
142cfde89edab3d4eabd6335458b4c8736cebfb6

compiled/facebook-www/React-dev.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,7 @@ __DEV__ &&
14821482
exports.useTransition = function () {
14831483
return resolveDispatcher().useTransition();
14841484
};
1485-
exports.version = "19.3.0-www-classic-94643c3b-20260421";
1485+
exports.version = "19.3.0-www-classic-142cfde8-20260422";
14861486
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
14871487
"function" ===
14881488
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

compiled/facebook-www/React-dev.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,7 @@ __DEV__ &&
14821482
exports.useTransition = function () {
14831483
return resolveDispatcher().useTransition();
14841484
};
1485-
exports.version = "19.3.0-www-modern-94643c3b-20260421";
1485+
exports.version = "19.3.0-www-modern-142cfde8-20260422";
14861486
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
14871487
"function" ===
14881488
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

compiled/facebook-www/React-prod.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,4 +610,4 @@ exports.useSyncExternalStore = function (
610610
exports.useTransition = function () {
611611
return ReactSharedInternals.H.useTransition();
612612
};
613-
exports.version = "19.3.0-www-classic-94643c3b-20260421";
613+
exports.version = "19.3.0-www-classic-142cfde8-20260422";

compiled/facebook-www/React-prod.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,4 +610,4 @@ exports.useSyncExternalStore = function (
610610
exports.useTransition = function () {
611611
return ReactSharedInternals.H.useTransition();
612612
};
613-
exports.version = "19.3.0-www-modern-94643c3b-20260421";
613+
exports.version = "19.3.0-www-modern-142cfde8-20260422";

compiled/facebook-www/React-profiling.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ exports.useSyncExternalStore = function (
614614
exports.useTransition = function () {
615615
return ReactSharedInternals.H.useTransition();
616616
};
617-
exports.version = "19.3.0-www-classic-94643c3b-20260421";
617+
exports.version = "19.3.0-www-classic-142cfde8-20260422";
618618
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
619619
"function" ===
620620
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

compiled/facebook-www/React-profiling.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ exports.useSyncExternalStore = function (
614614
exports.useTransition = function () {
615615
return ReactSharedInternals.H.useTransition();
616616
};
617-
exports.version = "19.3.0-www-modern-94643c3b-20260421";
617+
exports.version = "19.3.0-www-modern-142cfde8-20260422";
618618
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
619619
"function" ===
620620
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

compiled/facebook-www/ReactART-dev.classic.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20434,10 +20434,10 @@ __DEV__ &&
2043420434
(function () {
2043520435
var internals = {
2043620436
bundleType: 1,
20437-
version: "19.3.0-www-classic-94643c3b-20260421",
20437+
version: "19.3.0-www-classic-142cfde8-20260422",
2043820438
rendererPackageName: "react-art",
2043920439
currentDispatcherRef: ReactSharedInternals,
20440-
reconcilerVersion: "19.3.0-www-classic-94643c3b-20260421"
20440+
reconcilerVersion: "19.3.0-www-classic-142cfde8-20260422"
2044120441
};
2044220442
internals.overrideHookState = overrideHookState;
2044320443
internals.overrideHookStateDeletePath = overrideHookStateDeletePath;
@@ -20472,7 +20472,7 @@ __DEV__ &&
2047220472
exports.Shape = Shape;
2047320473
exports.Surface = Surface;
2047420474
exports.Text = Text;
20475-
exports.version = "19.3.0-www-classic-94643c3b-20260421";
20475+
exports.version = "19.3.0-www-classic-142cfde8-20260422";
2047620476
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
2047720477
"function" ===
2047820478
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

compiled/facebook-www/ReactART-dev.modern.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20205,10 +20205,10 @@ __DEV__ &&
2020520205
(function () {
2020620206
var internals = {
2020720207
bundleType: 1,
20208-
version: "19.3.0-www-modern-94643c3b-20260421",
20208+
version: "19.3.0-www-modern-142cfde8-20260422",
2020920209
rendererPackageName: "react-art",
2021020210
currentDispatcherRef: ReactSharedInternals,
20211-
reconcilerVersion: "19.3.0-www-modern-94643c3b-20260421"
20211+
reconcilerVersion: "19.3.0-www-modern-142cfde8-20260422"
2021220212
};
2021320213
internals.overrideHookState = overrideHookState;
2021420214
internals.overrideHookStateDeletePath = overrideHookStateDeletePath;
@@ -20243,7 +20243,7 @@ __DEV__ &&
2024320243
exports.Shape = Shape;
2024420244
exports.Surface = Surface;
2024520245
exports.Text = Text;
20246-
exports.version = "19.3.0-www-modern-94643c3b-20260421";
20246+
exports.version = "19.3.0-www-modern-142cfde8-20260422";
2024720247
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
2024820248
"function" ===
2024920249
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&

0 commit comments

Comments
 (0)