Skip to content

Bug: React Input Memory Leak in Chrome (ESM build only) #35094

@dhilt

Description

@dhilt

React input elements leak memory in Chrome when conditionally rendered. The leak occurs with ESM builds only (not UMD), and does not occur in Edge. The minimal workaround reduces but doesn't fully eliminate the leak.

React version: tested on v18 and v19

Steps To Reproduce

Essentially the repro is as follows.

function App() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && <ModalWithInput />}
    </>
  );
}

function ModalWithInput() {
  return (
    <div>
      <input />
      <h3>Leaking text...</h3>
    </div>
  );
}
  1. Make ESM build and run.
  2. Open in Chrome (not Edge)
  3. DevTools → Memory → Take heap snapshot
  4. Force garbage collection and take snapshot 1
  5. Click "Toggle" 20 times
  6. Force garbage collection and take snapshot 2
  7. Compare snapshot 2 to 1 and search for "Detached"
  8. Observe 10 Detached inputs and 10 h3 elements.

It means if you have a modal with hundreds of elements interacting with each other, you may easily fall into a serious issue. In my real case, 5 open/close operations led to ~1Mb leak.

I created a Stackblitz demo: https://stackblitz.com/edit/react-input-memory-leak.
Download it, build locally, run with vite (all included in the demo).
It's important to run it locally, not via stackblitz environment.
Also, as I said, running with umd-cdn (like https://unpkg.com/react@18/umd/react.production.min.js) will not give a repro.

Workaround

Below is the minimal workaround that eliminates h3 and other non-input elements from the memory, but not input elements themselves. In my real cases, by the way, it solves 97% of the memory leak issue.

function ModalWithInput() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    const input = inputRef.current;
    return () => {
      if (input) {
        input.remove();
      }
    };
  }, []);
  
  return (
    <div>
      <input ref={inputRef} />
      <h3>Leaking text...</h3>
    </div>
  );
}

Additional details

I dived into debugging at some point... All of the h3 items had similar retainers structure with one of the branches as follows:

Detached <h3>
  └─ Detached <input>
      └─ bound_argument_2 in native_bind()
          └─ node in system / Context
              ├─ context in get()@react-dom_client.js:1510
              ├─ context in getValue()@react-dom_client.js:1523
              ├─ context in set()@react-dom_client.js:1513
              ├─ context in setValue()@react-dom_client.js:1526
              └─ context in stopTracking()@react-dom_client.js:1530

The input element retains its parent tree through the _valueTracker closures. The closures capture a context object that holds references to the entire DOM subtree. I checked the implementation.

// From react-dom_client.js
input._valueTracker = {
  getValue: function() {   
    // Closure captures 'context' which holds parent references
    return currentValue;
  },
  setValue: function(value) {
    // Closure captures 'context' which holds parent references
    set.call(this, value);
  },
  stopTracking: function() { 
    // Closure captures 'context'
    ...
  }
};

// Custom property descriptor
Object.defineProperty(input, 'value', {
  get: function() {    
    // Closure captures 'context'
    return valueTracker.getValue();
  },
  set: function(value) {  
    // Closure captures 'context'
    valueTracker.setValue(value);
  }
});

This is probably why the Parent tree is retained:

  1. Input creates _valueTracker with closures (getValue, setValue, stopTracking)
  2. Each closure captures context via native_bind()
  3. Context holds references to parent nodes (div, h3, siblings)
  4. When component unmounts, React doesn't clean up _valueTracker
  5. Chrome's GC sees circular refs and marks everything as retained
  6. Result: Input + entire parent tree stuck in memory

And this is probably why input.remove() works:

  1. input.remove() called before React unmount completes
  2. Input detaches from parent → breaks DOM parent-child link
  3. Parent div and h3 no longer have child references to input
  4. Chrome's GC can collect parent tree (div, h3) → they're unreferenced
  5. Input still retained due to _valueTracker closures, BUT
  6. Closures' context no longer keeps parents alive → parents already GC'd

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions