Destroy
Correct teardown is as important as a working mount. A component opened thousands of times must release every resource it holds on close.
const handle = mount(Component, root);
handle.unmount(); // DOM removed, bindings disposed, listeners gone,
// states unsubscribed, refs nulled, provably
The problem
LemonadeJS v5 had a wound here: instances were nearly impossible to fully
destroy. Listeners registered on document were never removed (the v5
router and wheel leak theirs to this day), subscriptions outlived their
owners, and memory only grew.
The root cause is general: when cleanup is the author’s responsibility, leaks are the default. A closure holding a removed element pins the entire detached subtree. A subscription without explicit disposal survives its owner. Neither fails immediately or loudly.
For agent-written code the stakes are higher: an agent will not notice the memory tab creeping. Destruction must be structural, and gated.
How LemonadeJS solves it
Everything a component creates is owned by its instance. States created
with the state tool, derivations from computed, bindings, child
instances, branch entries, all registered to the instance and disposed by
unmount(), recursively, children first. There is no path where a binding
survives its component.
Cleanup is the return value, not a second registration:
onMount(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
});
External DOM listeners need even less: listen(document, 'mousedown', closer) is removed by disposal automatically; raw addEventListener
never appears in component code (see Lifecycle).
Refs cannot pin corpses. Object refs (ref()) are nulled when the DOM
they point to is disposed, branch swaps included, not only full unmounts.
A ref held in a long-lived closure never retains a dead subtree. The engine
also blurs any focused node it removes: the document’s “last focused
element” reference is a classic accidental retainer.
Web components destroy by default. A custom element removed from the DOM unmounts its instance (with a one-microtask grace so same-tick moves survive). The v5 policy was keep-alive, which meant every React/Vue host that removed an element leaked a subscribed tree. The policy flipped, and the reasoning is documented in the source.
Interactions hold one cleanup, not one per gesture. Drag systems (the modal’s move/resize, the datagrid’s column resize, the slider, the gantt) register a single persistent release; an unmount mid-drag releases the in-flight listeners. The test suites assert listener balance: counts after stacked gestures and mid-drag unmounts return to baseline.
isDisposing(), the focus corollary
Destruction has a subtle observable side effect: removing a focused element
makes the browser fire focusout exactly as if the user clicked away.
Three Studio blocks independently hit this (an editor that committed on
blur, a dropdown that closed mid-open, a dock that lost its click). The
engine now exposes the truth:
onfocusout="${(e) => {
if (isDisposing()) return; // the renderer caused this, not the user
close();
}}"
isDisposing() is true while the renderer is removing or blurring DOM,
branch swaps, slot detaches, unmounts. One line replaces every hand-rolled
mute flag.
The receipts
The claims are gated, not promised. The library’s own suite runs, on every change:
- Listener balance accounting.
document.addEventListenerandremoveEventListenerare counted across stress cycles (including unmounts mid-drag); the delta must be zero. - WeakRef collectability. Component instances and their DOM are held
via
WeakRefacross forced GC; they must be collected. (The methodology matters and is documented in the tests: never dereference between GC passes, because V8’s conservative stack scanning pins what you touch, and flush jsdom’s per-selector query cache, which famously retains the last result set.) - Heap growth. 600 create/interact/destroy cycles must stay under 4MB of growth.
- Real Chrome, real numbers. 100,000 modal create/open/drag/destroy
cycles in a live browser: zero detached DOM nodes in the heap
snapshot afterwards; the only growth was V8’s JIT warming up (~270KB of
compiled code), which plateaus. The snapshot-diff tool that proved it
ships in the repo (
scripts/diff-snapshots.mjs) so the claim can be re-verified on any machine.
If you build a block: verify() plus the stress patterns above are the
bar. A component that cannot die cleanly does not enter the registry.