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)
poll(el) (around line 178) runs setInterval(() => lowPriority(updatePos.bind(null, el)), 2000). lowPriority() (around line 187) queues a requestIdleCallback.
- 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.
- 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.
Summary
In 0.10.0, an element's subtree can stay alive after
destroy(). This also happens whenuseAutoAnimateunmounts. A low-priority position update that was queued beforedestroy()runs after it, andobservePosition()then creates a newIntersectionObserveron the now-detached element. Its root isdocument.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)poll(el)(around line 178) runssetInterval(() => lowPriority(updatePos.bind(null, el)), 2000).lowPriority()(around line 187) queues arequestIdleCallback.destroy()(around line 776) clears intervals, debounces, intersections and coords, but an idle callback that is already queued still runs.updatePos(el). After its debounce,updatePoscallsobservePosition(el)(around line 102), which disconnects the old observer (already gone) and creates a new one on the detached element.Repro
destroy()and ran after it.Suggested fix
In
observePosition(), right after the old observer is disconnected: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.
destroy().Observing a disconnected element measures nothing, so no animation behaviour is lost. Happy to open a PR if that's useful.