Skip to content

Memory leak in Popover, possibly other components #805

Description

@bendemboski

<Popover> components leak some observers after being un-rendered. The mechanism for the leak is that the anchorTo modifier cleanup fun ction does not get run when un-rendering a <Popover>, although I have no idea why.

I created a branch with one commit that demonstrates the behavior. It hacks in some counting and console logs, and then implements a single test that renders and un-renders a <Popover> and asserts that the number of times the anchorTo modifiers install code ran equals the number of times its cleanup code ran. If you run only that test, you will see it fail, saying that the count is 1, i.e. the cleanup code never ran, which you can also see in the console logs.

I have no clue why the modifier's destructor is not running. Instead of clearRender() I also tried wrapping the <Popover> in an if block and un-rendering it that way, and it produced the same result.

The effect of this is that @floating-ui/dom's cleanup code never runs, and it leaks some observers (resize and maybe others?), causing a whole bunch of modifier/component infrastructure to leak as well (and in the case of rendering tests, the entire application instance!).

Activity

  1. NullVoxPopuli-ai-agent commented on Sep 4, 2026

    @NullVoxPopuli-ai-agent
    Contributor

    Root cause is in Glimmer VM, not in this addon. Any dynamic modifier whose value changes after the first render leaks its destructor. This is present in every Ember release since early 2021.

    How <Popover> hits it:

    1. <FloatingUI> yields floating as (if this.reference (modifier anchorTo this.reference)). On the first render this.reference is not set, so floating is undefined.
    2. The reference modifier sets this.reference after the first render. Glimmer then re-evaluates {{@floating}} on the content element and creates the anchorTo instance.
    3. UpdateDynamicModifierOpcode.evaluate attaches that instance's destroyable to the updating opcode itself, with associateDestroyableChild(this, destroyable).
    4. Nothing registers that opcode as a destroyable child of its block. vm.updateWith only pushes it to the updating list. When the block is torn down, the opcode is not destroyed, and the destructor never runs.

    The line comes from glimmer-vm commit cb11136 (February 2021).

    Reproduction with no addon code involved. A <div {{state.mod}}> where state.mod starts as undefined and later becomes a modifier never runs its destructor on clearRender() or when an {{#if}} around it goes false. The same modifier defined on the first render is torn down correctly.

    Verified fix in the DynamicModifier append opcode: register the updating opcode with the block before pushing it.

    let updateOpcode = new UpdateDynamicModifierOpcode(tag, instance, instanceRef);
    vm.associateDestroyable(updateOpcode);
    return vm.updateWith(updateOpcode);

    With that patch applied to a local ember-source build, the reproduction passes.

    Next steps:

    • I will open a pull request on emberjs/ember.js with a failing test and the fix. I will link it here.
    • This addon can also work around the bug on unpatched Ember versions. Yield a stable modifier and pass the reference as an argument, instead of switching the yielded value from undefined to a modifier. A stable curried modifier whose argument goes from undefined to an element is torn down correctly.
  2. NullVoxPopuli-ai-agent commented on Sep 4, 2026

    @NullVoxPopuli-ai-agent
    Contributor

    Pull request with a failing test and the fix: emberjs/ember.js#21591

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions