Fix FragmentInstance listener leak: normalize boolean vs object capture options per DOM spec#716
Fix FragmentInstance listener leak: normalize boolean vs object capture options per DOM spec#716
Conversation
…ismatch between boolean and object capture optionsn ReactFiberConfigDOM
…lization in FragmentInstance addEventListener/removeEventListenereners in FragmentRefs
Greptile SummaryFixes a listener key mismatch in
Confidence Score: 5/5
Important Files Changed
Prompt To Fix All With AIThis is a comment left during a code review.
Path: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
Line: 817-855
Comment:
**Inconsistent indentation with sibling tests**
Both new `it()` blocks are indented at 8 spaces, but every other `it()` inside this `describe('add/remove event listeners')` block uses 6 spaces (e.g., lines 788, 898). This is a minor style nit but could cause confusion when scanning the file.
```suggestion
// @gate enableFragmentRefs
it(
'removes a capture listener registered with boolean when removed with options object',
async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
<Fragment ref={fragmentRef}>
<div id="child-a" />
</Fragment>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Test />);
});
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([]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
Line: 857-895
Comment:
**Same indentation issue as above**
This second test block also has 8-space indentation where it should be 6 spaces to match the rest of the `describe('add/remove event listeners')` block.
```suggestion
// @gate enableFragmentRefs
it(
'removes a capture listener registered with options object when removed with boolean',
async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
<Fragment ref={fragmentRef}>
<div id="child-b" />
</Fragment>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Test />);
});
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([]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: c380e05 |
| // @gate enableFragmentRefs | ||
| it( | ||
| 'removes a capture listener registered with boolean when removed with options object', | ||
| async () => { | ||
| const fragmentRef = React.createRef(null); | ||
| function Test() { | ||
| return ( | ||
| <Fragment ref={fragmentRef}> | ||
| <div id="child-a" /> | ||
| </Fragment> | ||
| ); | ||
| } | ||
| const root = ReactDOMClient.createRoot(container); | ||
| await act(() => { | ||
| root.render(<Test />); | ||
| }); | ||
|
|
||
| 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([]); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
Inconsistent indentation with sibling tests
Both new it() blocks are indented at 8 spaces, but every other it() inside this describe('add/remove event listeners') block uses 6 spaces (e.g., lines 788, 898). This is a minor style nit but could cause confusion when scanning the file.
| // @gate enableFragmentRefs | |
| it( | |
| 'removes a capture listener registered with boolean when removed with options object', | |
| async () => { | |
| const fragmentRef = React.createRef(null); | |
| function Test() { | |
| return ( | |
| <Fragment ref={fragmentRef}> | |
| <div id="child-a" /> | |
| </Fragment> | |
| ); | |
| } | |
| const root = ReactDOMClient.createRoot(container); | |
| await act(() => { | |
| root.render(<Test />); | |
| }); | |
| 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 boolean when removed with options object', | |
| async () => { | |
| const fragmentRef = React.createRef(null); | |
| function Test() { | |
| return ( | |
| <Fragment ref={fragmentRef}> | |
| <div id="child-a" /> | |
| </Fragment> | |
| ); | |
| } | |
| const root = ReactDOMClient.createRoot(container); | |
| await act(() => { | |
| root.render(<Test />); | |
| }); | |
| 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([]); | |
| }, | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
Line: 817-855
Comment:
**Inconsistent indentation with sibling tests**
Both new `it()` blocks are indented at 8 spaces, but every other `it()` inside this `describe('add/remove event listeners')` block uses 6 spaces (e.g., lines 788, 898). This is a minor style nit but could cause confusion when scanning the file.
```suggestion
// @gate enableFragmentRefs
it(
'removes a capture listener registered with boolean when removed with options object',
async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
<Fragment ref={fragmentRef}>
<div id="child-a" />
</Fragment>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Test />);
});
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([]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| // @gate enableFragmentRefs | ||
| it( | ||
| 'removes a capture listener registered with options object when removed with boolean', | ||
| async () => { | ||
| const fragmentRef = React.createRef(null); | ||
| function Test() { | ||
| return ( | ||
| <Fragment ref={fragmentRef}> | ||
| <div id="child-b" /> | ||
| </Fragment> | ||
| ); | ||
| } | ||
| const root = ReactDOMClient.createRoot(container); | ||
| await act(() => { | ||
| root.render(<Test />); | ||
| }); | ||
|
|
||
| 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([]); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
Same indentation issue as above
This second test block also has 8-space indentation where it should be 6 spaces to match the rest of the describe('add/remove event listeners') block.
| // @gate enableFragmentRefs | |
| it( | |
| 'removes a capture listener registered with options object when removed with boolean', | |
| async () => { | |
| const fragmentRef = React.createRef(null); | |
| function Test() { | |
| return ( | |
| <Fragment ref={fragmentRef}> | |
| <div id="child-b" /> | |
| </Fragment> | |
| ); | |
| } | |
| const root = ReactDOMClient.createRoot(container); | |
| await act(() => { | |
| root.render(<Test />); | |
| }); | |
| 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( | |
| 'removes a capture listener registered with options object when removed with boolean', | |
| async () => { | |
| const fragmentRef = React.createRef(null); | |
| function Test() { | |
| return ( | |
| <Fragment ref={fragmentRef}> | |
| <div id="child-b" /> | |
| </Fragment> | |
| ); | |
| } | |
| const root = ReactDOMClient.createRoot(container); | |
| await act(() => { | |
| root.render(<Test />); | |
| }); | |
| 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([]); | |
| }, | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
Line: 857-895
Comment:
**Same indentation issue as above**
This second test block also has 8-space indentation where it should be 6 spaces to match the rest of the `describe('add/remove event listeners')` block.
```suggestion
// @gate enableFragmentRefs
it(
'removes a capture listener registered with options object when removed with boolean',
async () => {
const fragmentRef = React.createRef(null);
function Test() {
return (
<Fragment ref={fragmentRef}>
<div id="child-b" />
</Fragment>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Test />);
});
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([]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…y 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"
Mirror of facebook/react#36047
Original author: Dhakshin2007
Summary
FragmentInstance.addEventListenerandremoveEventListenerfail to cross-match listeners when thecaptureoption is passed as a boolean in one call and an options object in the other. This violates the DOM Living Standard, which states thataddEventListener(type, fn, true)andaddEventListener(type, fn, {capture: true})are identical.Root Cause
In
ReactFiberConfigDOM.js, thenormalizeListenerOptionsfunction generates a listener key string for deduplication. The boolean branch generates a different format than the object branch:Because the keys differ,
indexOfEventListenercannot match them — soremoveEventListener('click', fn, {capture: true})silently fails to remove a listener registered withaddEventListener('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:
This makes both forms produce an identical key, matching the DOM spec behavior.
When Was This Introduced
This bug has been present since
FragmentInstanceevent listener tracking was first added. It became reachable in production as of #36026 which enabledenableFragmentRefs+enableFragmentRefsInstanceHandlesacross all builds (merged 3 days ago).Tests
Added two new regression tests to
ReactDOMFragmentRefs-test.js:removes a capture listener registered with boolean when removed with options objectremoves a capture listener registered with options object when removed with booleanBoth 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
FragmentInstance.removeEventListener()not removing capture-phase listeners when thecaptureoption form (boolean vs options object) differs betweenaddandremovecalls.