Repository navigation
[BUG] Unmounted layoutId nodes are never released from the projection root's sharedNodes (memory leak) #3940
Description
2. Describe the bug
When an element with a layoutId unmounts and it was the only member of its NodeStack, the stack is never removed from the projection root's sharedNodes map, and the stack's lead keeps pointing at the unmounted projection node. That node still holds options.visualElement, whose props hold every closure passed to the component (event handlers etc.), so anything those closures capture is retained for the lifetime of the page.
This becomes an unbounded leak whenever layoutId is unique per instance, which is a common pattern (e.g. a tab highlight or a popover that derives its layoutId from useId() so several instances on screen don't animate into each other). Every mount adds a new entry to sharedNodes that is never released.
In our production app, opening and closing a few drawers that contain tabs leaked ~1,200 detached DOM nodes per round trip (the retained props closures reached the unmounted drawer DOM).
3. CodeSandbox reproduction
https://codesandbox.io/p/sandbox/mg79v6 (preview: https://mg79v6.csb.app/)
It mounts and unmounts 20 motion.divs per cycle, 5 cycles, each capturing an ~8 MB payload in its onClick closure that is tracked with a WeakRef. The control group renders the same component without layoutId.
4. Steps to reproduce
- Open the preview, click "Run 5 cycles WITH unique layoutId", then "Run 5 cycles WITHOUT layoutId".
- DevTools → Memory → "Collect garbage".
- Click "Count retained".
- Result (Chrome 155,
motion@14.3.0, React 19.2):{ "withLayoutId": "100 / 100 still alive", "withoutLayoutId": "0 / 100 still alive" }
Retainer path of one of the payloads, from a heap snapshot:
projection root .sharedNodes (Map)
→ NodeStack .lead // the unmounted node
→ .options.visualElement .props.onClick
→ closure context → payload
5. Expected behavior
Once the last node with a given layoutId has unmounted (and no new node with the same layoutId takes its place), the NodeStack should be dropped from sharedNodes so the unmounted node can be garbage collected.
Cause
NodeStack.remove()only promotes another member when there is one. When the removed node was the last member,leadkeeps pointing at it.unmount()callsstack.remove(this)but nothing ever deletes an empty stack fromroot.sharedNodes(there is nosharedNodes.deleteanywhere; stacks are only created inregisterSharedNode).
Possible fix
The stack can't simply be deleted inside unmount(), because when a shared-layout element is swapped in the same commit (old element unmounts, new element with the same layoutId mounts — e.g. a highlight moving between tabs), the new node relies on the stack's lead to resume from the old node's snapshot.
What we're running as a local patch (motion-dom@12.43.0; verified by frame sampling that the tab-highlight shared-layout transition still animates, including after the element has been idle for >1s and after remounting, and the leak goes from ~1,200 nodes per round trip to zero growth):
unmount() {
this.options.layoutId && this.willUpdate()
this.root.nodes.remove(this)
const stack = this.getStack()
stack && stack.remove(this)
if (stack && stack.members.length === 0) {
const root = this.root
const layoutId = this.options.layoutId
setTimeout(() => {
if (stack.members.length === 0 && root.sharedNodes.get(layoutId) === stack) {
root.sharedNodes.delete(layoutId)
}
}, 1000)
}
// ...
}A cleaner version would probably do the check at the end of the root's update cycle (after any same-commit handoff has happened) rather than with a timer — happy to open a PR if that direction sounds right.
7. Environment details
motion14.3.0 (also reproduced on 12.43.0), React 19.2.0- Chrome 155, macOS
Activity
Release empty shared layout stacks from the projection root