Call stopTracking() on host component unmount to prevent input memory leak#36223
Call stopTracking() on host component unmount to prevent input memory leak#36223stewartmcgown wants to merge 1 commit intofacebook:mainfrom
Conversation
… 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
|
Hi @stewartmcgown! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
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:

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

Verified after this patch that stopTracking() is called:

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

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.jsexports astopTracking()function that restores the nativevalueproperty 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()callsObject.defineProperty(node, 'value', { get, set })to intercept value changes. On unmount,detachDeletedInstance()cleans up__reactFiber$,__reactProps$, and event handler references — but never restores the nativevaluedescriptor.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::Persistenthandle 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 directcppgc::Persistenthandle under "C++ Persistent roots" — the only DOM element with such a handle. All JavaScript-level retention paths were weak. Tracing through V8'scpp-snapshot.ccand Chromium'sReactDOMComponentTreeled to theObject.definePropertyoverride as the only difference between input elements and other DOM nodes.The fix
Add
stopTracking(node)todetachDeletedInstance(). For non-input elements,stopTrackingis a no-op (returns early when_valueTrackeris absent). For tracked inputs, it:node._valueTracker = nullvalueproperty, restoring the native descriptor from the prototypeTest plan
<input>inside a conditionally mounted component (e.g. a modal)<input>elements with_valueTrackerstill set