Skip to content

0.10.0: destroy() can leave an IntersectionObserver on a detached element (idle callback re-arms observePosition) #246

Description

@AndreasOhlsson

Summary

In 0.10.0, an element's subtree can stay alive after destroy(). This also happens when useAutoAnimate unmounts. A low-priority position update that was queued before destroy() runs after it, and observePosition() then creates a new IntersectionObserver on the now-detached element. Its root is document.documentElement, so the observer, and the detached subtree its callback closes over, live for the rest of the page session. Nothing disconnects it again.

This is related to #180 (timers in older versions), but it is a separate path that destroy() doesn't cover.

How it happens (0.10.0, index.mjs)

  1. poll(el) (around line 178) runs setInterval(() => lowPriority(updatePos.bind(null, el)), 2000). lowPriority() (around line 187) queues a requestIdleCallback.
  2. The component unmounts while the main thread is busy, e.g. a route change. destroy() (around line 776) clears intervals, debounces, intersections and coords, but an idle callback that is already queued still runs.
  3. That callback calls updatePos(el). After its debounce, updatePos calls observePosition(el) (around line 102), which disconnects the old observer (already gone) and creates a new one on the detached element.

Repro

  • Scenario: in Chromium, a parent with 10 animated children; unmount it during a ~600 ms busy main-thread window; force GC.
  • 0.10.0 as published: the detached subtree survived GC in 11 of 12 trials. All leaked observers were created 200–600 ms after an idle callback that was queued before destroy() and ran after it.
  • In a real app: we saw a steady leak of one timeline subtree every few navigations. 0.8.2 leaked on every navigation.

Suggested fix

In observePosition(), right after the old observer is disconnected:

if (!el.isConnected) {
  intersections.delete(el)
  return
}

Every path that re-creates an observer goes through this function (idle callbacks, the root resize update, observer and animation callbacks), so one guard covers all of them.

  • 0.10.0 with the guard: 0 of 12 trials leaked, even though idle callbacks still ran after destroy().
  • Over 60 navigations in our app: DOM nodes and event listeners stayed flat.

Observing a disconnected element measures nothing, so no animation behaviour is lost. Happy to open a PR if that's useful.

Activity

  1. AndreasOhlsson commented on Oct 8, 2026

    @AndreasOhlsson
    Author

    Fix proposed in #247 (a guard in observePosition()). It complements #242, which handles the timer side of #180.

  2. added a commit that references this issue on Oct 8, 2026
    c029f01
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