Files
react/packages/react-dom
Kotha Dhakshin 142cfde89e 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](https://github.com/facebook/react/pull/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.
2026-04-22 09:40:34 -04:00
..

react-dom

This package serves as the entry point to the DOM and server renderers for React. It is intended to be paired with the generic React package, which is shipped as react to npm.

Installation

npm install react react-dom

Usage

In the browser

import { createRoot } from 'react-dom/client';

function App() {
  return <div>Hello World</div>;
}

const root = createRoot(document.getElementById('root'));
root.render(<App />);

On the server

import { renderToPipeableStream } from 'react-dom/server';

function App() {
  return <div>Hello World</div>;
}

function handleRequest(res) {
  // ... in your server handler ...
  const stream = renderToPipeableStream(<App />, {
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/html');
      stream.pipe(res);
    },
    // ...
  });
}

API

react-dom

See https://react.dev/reference/react-dom

react-dom/client

See https://react.dev/reference/react-dom/client

react-dom/server

See https://react.dev/reference/react-dom/server