The README documents destroy() as:
// Use in cleanup lifecycle hooks of your application as a whole
// This will remove the in memory data store holding onto all of the observers
intersectionObserverAdmin.destroy();
In my AI-assisted memory leak expedition, the current implementation (v0.3.4) doesn't deliver that — calling destroy() leaves every IntersectionObserver instance the admin created alive, with its native callbacks and any bound_this contexts they captured.
Current implementation
// src/index.ts
destroy() {
this.elementRegistry.destroyRegistry();
}
// Registry#destroyRegistry:
destroyRegistry() {
this.registry = new WeakMap();
}
destroyRegistry() replaces the WeakMap that maps roots (e.g. window or a scrollable container) to { [stringifiedOptions]: { intersectionObserver, elements, options } } entries. But:
- No
disconnect() is called on any of the IntersectionObserver instances stored in those entries.
- No reference to those observers is cleared — the old WeakMap + its values + their observers are garbage only if nothing else reaches them. In practice, Chrome's native
IntersectionObserver is retained by the platform's C++ observer table, which keeps the JS callback and its closure alive.
Observed consequence
Investigating a test-memory leak in our Ember app (ember-in-viewport@4.1 consumer, which calls observerAdmin.destroy() in its service willDestroy), heap-snapshot retainer traces show the retained subjects are bound methods on consumer components / modifiers:
synthetic::(Traced handles) → weak:N → <observed element>
→ WeakMap part-of-key
→ Object (notifications entry)
→ property:enter → closure::native_bind
→ bound_this = ConsumerModifier
→ <symbol OWNER> = ApplicationInstance (Ember container that should have been GC'd)
(The Traced handles → weak:N edge is how Chrome's heap snapshot models a native observer holding a JS object — it reads as "weak" in the snapshot but is strong on the C++ side.)
PR #58 (merged July 2024 / v0.3.4) fixed the unobserve(element) path so consumers explicitly unobserving each element before teardown gets memory reclaimed. But consumers that rely on destroy() as documented — for whole-app cleanup where they haven't tracked every individual element — don't get the observers released. This scenario would be common in test suites.
Reproduction
import IntersectionObserverAdmin from 'intersection-observer-admin';
const admin = new IntersectionObserverAdmin();
const el = document.createElement('div');
document.body.appendChild(el);
admin.observe(el, { root: window, rootMargin: '0px', threshold: 0 });
// Observer is now observing el.
// Per README, destroy() should now "remove the in memory data store
// holding onto all of the observers":
admin.destroy();
document.body.removeChild(el);
// A manual `performance.memory` snapshot / `queryObjects(IntersectionObserver)`
// in devtools shows the IntersectionObserver still alive. Its callback (the
// result of `setupOnIntersection(options).bind(this)`) still references the
// admin.
In a larger consumer (using ember-in-viewport in an Ember app with a normal QUnit test harness), the result is that every ApplicationInstance that had any observed element stays retained across the whole suite, because the native observer holds the element (and thus the WeakMap-keyed-by-element notification entries, and through their bound callbacks, the consumer components and their Ember owner).
Proposed fix
The admin needs to track the observers it creates so destroy() can .disconnect() them. One shape that works without introducing a different leak:
class IntersectionObserverAdmin extends Notifications {
private observersByRoot = new Map<Element | Window, Set<IntersectionObserver>>();
newObserver(element, options) {
const { root = window, rootMargin, threshold } = options;
const io = new IntersectionObserver(
this.setupOnIntersection(options).bind(this),
{ root, rootMargin, threshold },
);
io.observe(element);
let perRoot = this.observersByRoot.get(root);
if (!perRoot) {
perRoot = new Set();
this.observersByRoot.set(root, perRoot);
}
perRoot.add(io);
return io;
}
destroy() {
for (const set of this.observersByRoot.values()) {
for (const io of set) {
try { io.disconnect(); } catch { /* best-effort */ }
}
}
this.observersByRoot.clear();
this.elementRegistry.destroyRegistry();
}
}
If you're open to a PR with this approach, I'm happy to submit one. Thanks for your work on this lib.
The README documents
destroy()as:In my AI-assisted memory leak expedition, the current implementation (v0.3.4) doesn't deliver that — calling
destroy()leaves everyIntersectionObserverinstance the admin created alive, with its native callbacks and anybound_thiscontexts they captured.Current implementation
destroyRegistry()replaces the WeakMap that maps roots (e.g.windowor a scrollable container) to{ [stringifiedOptions]: { intersectionObserver, elements, options } }entries. But:disconnect()is called on any of theIntersectionObserverinstances stored in those entries.IntersectionObserveris retained by the platform's C++ observer table, which keeps the JS callback and its closure alive.Observed consequence
Investigating a test-memory leak in our Ember app (
ember-in-viewport@4.1consumer, which callsobserverAdmin.destroy()in its servicewillDestroy), heap-snapshot retainer traces show the retained subjects are bound methods on consumer components / modifiers:(The
Traced handles → weak:Nedge is how Chrome's heap snapshot models a native observer holding a JS object — it reads as "weak" in the snapshot but is strong on the C++ side.)PR #58 (merged July 2024 / v0.3.4) fixed the
unobserve(element)path so consumers explicitly unobserving each element before teardown gets memory reclaimed. But consumers that rely ondestroy()as documented — for whole-app cleanup where they haven't tracked every individual element — don't get the observers released. This scenario would be common in test suites.Reproduction
In a larger consumer (using ember-in-viewport in an Ember app with a normal QUnit test harness), the result is that every
ApplicationInstancethat had any observed element stays retained across the whole suite, because the native observer holds the element (and thus the WeakMap-keyed-by-element notification entries, and through their bound callbacks, the consumer components and their Ember owner).Proposed fix
The admin needs to track the observers it creates so
destroy()can.disconnect()them. One shape that works without introducing a different leak:If you're open to a PR with this approach, I'm happy to submit one. Thanks for your work on this lib.