Skip to content

[BUG] Unmounted layoutId nodes are never released from the projection root's sharedNodes (memory leak) #3940

Closed

Description

@mumingfang

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

  1. Open the preview, click "Run 5 cycles WITH unique layoutId", then "Run 5 cycles WITHOUT layoutId".
  2. DevTools → Memory → "Collect garbage".
  3. Click "Count retained".
  4. 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, lead keeps pointing at it.
  • unmount() calls stack.remove(this) but nothing ever deletes an empty stack from root.sharedNodes (there is no sharedNodes.delete anywhere; stacks are only created in registerSharedNode).

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

  • motion 14.3.0 (also reproduced on 12.43.0), React 19.2.0
  • Chrome 155, macOS

Activity

  1. added a commit that references this issue on Oct 16, 2026
    9de7d6e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions