SIGN IN SIGN UP
facebook / react UNCLAIMED

The library for web and native user interfaces.

0 0 114 JavaScript

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.
K
Kotha Dhakshin committed
142cfde89edab3d4eabd6335458b4c8736cebfb6
Parent: 94643c3
Committed by GitHub <noreply@github.com> on 4/22/2026, 1:40:34 PM