Skip to content

Call stopTracking() on host component unmount to prevent input memory leak#36223

Draft
stewartmcgown wants to merge 1 commit intofacebook:mainfrom
stewartmcgown:fix/stop-tracking-on-unmount
Draft

Call stopTracking() on host component unmount to prevent input memory leak#36223
stewartmcgown wants to merge 1 commit intofacebook:mainfrom
stewartmcgown:fix/stop-tracking-on-unmount

Conversation

@stewartmcgown
Copy link
Copy Markdown

@stewartmcgown stewartmcgown commented Apr 6, 2026

Summary

Hi folks I work on an extremely large SPA and have been hunting a memory leak with elements retaining detached DOM trees on modal unmount.

From my understanding of the code this has existed for a long time. stopTracking() has been exported from inputValueTracking.js since it was written, but it is never called at any point in unmount. It seems like nothing more than an oversight.

Due to the fact that this property on the input does not show a JS retainer in heapsnapshots, it seems to have evaded detection this long. If we had captured scope, we'd see the scope. But since its just an element that is being retained for no obvious JS reason, it shows as connected to C++ Persisent Roots - this is where most of the time you stop looking as a JS dev as it seems like a dead end.

On 18.3.1, _wrapperState also needs to be deleted. I notice it has been removed from most recent versions of React.

Note that we are never calling either of these methods at runtime.
https://github.com/search?q=repo%3Afacebook%2Freact%20detachTracker&type=code
https://github.com/search?q=repo%3Afacebook%2Freact+stopTracking&type=code

Heap:
image

Before patch, stopTracking() is never called for an conditionally rendered :
image

Verified after this patch that stopTracking() is called:
image

Verified that deleting all exotic props in detach leaves the element 'clean' and able to be GC'd
image

AI Summary of the fix, and the analysis I did of the heapsnapshot that found persistent roots only as retainers rather than JS callbacks:

====

inputValueTracking.js exports a stopTracking() function that restores the native value property descriptor on controlled <input>, <textarea>, and <select> elements. However, it is never called — it has been dead code since it was introduced in React 15.6 (2017).

During mount, track() calls Object.defineProperty(node, 'value', { get, set }) to intercept value changes. On unmount, detachDeletedInstance() cleans up __reactFiber$, __reactProps$, and event handler references — but never restores the native value descriptor.

The custom property descriptor keeps the element's V8 wrapper permanently reachable to Chromium's unified garbage collector (cppgc). The overridden accessor makes the wrapper "interesting" to Blink's tracing, which creates a cppgc::Persistent handle that pins the detached element — and through DOM parent/child references, its entire subtree — indefinitely.

This is the root cause behind several long-standing memory leak reports:

How it was found

While investigating a command bar memory leak in a production app, heap snapshot analysis showed that detached <input> elements had a direct cppgc::Persistent handle under "C++ Persistent roots" — the only DOM element with such a handle. All JavaScript-level retention paths were weak. Tracing through V8's cpp-snapshot.cc and Chromium's ReactDOMComponentTree led to the Object.defineProperty override as the only difference between input elements and other DOM nodes.

The fix

Add stopTracking(node) to detachDeletedInstance(). For non-input elements, stopTracking is a no-op (returns early when _valueTracker is absent). For tracked inputs, it:

  1. Sets node._valueTracker = null
  2. Deletes the overridden value property, restoring the native descriptor from the prototype

Test plan

  • Render a controlled <input> inside a conditionally mounted component (e.g. a modal)
  • Mount/unmount 10 times, force GC, take heap snapshot
  • Before fix: 10 detached <input> elements with _valueTracker still set
  • After fix: 0 detached input elements retained

… leak

`inputValueTracking.js` overrides the native `value` property on
controlled `<input>`, `<textarea>`, and `<select>` elements via
`Object.defineProperty` when `track()` is called during mount. The
corresponding `stopTracking()` function that restores the native
descriptor was exported but never called — it is dead code.

The property override keeps the element's V8 wrapper permanently
reachable to Blink's unified garbage collector (the custom accessor
makes the wrapper "interesting" to cppgc tracing), which creates a
persistent handle that pins the detached element and its entire DOM
subtree indefinitely after unmount.

This is the root cause behind several long-standing memory leak reports
involving controlled inputs in modals and conditionally rendered
components (facebook#17581, facebook#20088, facebook#14962, facebook#26069, facebook#16087).

The fix calls `stopTracking(node)` in `detachDeletedInstance()` — the
same function that already cleans up `__reactFiber$`, `__reactProps$`,
and other instance properties during the commit deletion phase. For
non-input elements `stopTracking` is a no-op (early return when
`_valueTracker` is absent).

Made-with: Cursor
@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Apr 6, 2026

Hi @stewartmcgown!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@stewartmcgown stewartmcgown marked this pull request as draft April 6, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant