Summary
onMount(() => { …; return () => teardown() }) works in exactly one place in the runtime. stx.mountEl's component factory captures the return value and pushes it into the local destroy hooks; every other place that flushes mount callbacks — the DOMContentLoaded page path, the global flush, the stream/hydrate path, the SPA stx:load path, and every scoped __mountCallbacks drain — calls fn() and throws the result away.
The failure is silent. The teardown simply never runs, and there is no warning that a function was returned and dropped.
Impact (real app)
analyticshq polls a realtime endpoint from the dashboard. resources/views/dashboard.stx:1061-1063:
// Kept as well as the onDestroy hook: this one fires on the fragment-swap path, the
// hook covers the document-swap path. Neither covers both.
return () => live.pause()
})That belief is wrong, and the comment will be copied by the next maintainer. The returned closure never runs on any path. The poll is stopped only by useInterval's internal onDestroy hook (packages/stx/src/signals.ts:4400), drained by the stx:load destroy sweep.
The cost of getting this wrong is on record in the same file (dashboard.stx:1038-1043):
useInterval, not setInterval, and this is a bug fix rather than a style change. […] The hand-rolled timer relied solely on the teardown returned from onMount, and a full-document swap never runs it, so navigating away from the dashboard left the poll hitting /api/sites/:id/realtime every 15 seconds, forever, for a page nobody was looking at.
The earlier version of that poll leaked a request every 15s for the life of the tab because the author reasonably assumed the returned function was the teardown.
Evidence in stx
The one site that honours it — packages/stx/src/signals.ts:5996-6016 (inside mountEl):
var localMountHooks = mountCallbacks.splice(mountStart);
var localDestroyHooks = destroyCallbacks.splice(destroyStart);
…
localMountHooks.forEach(function(fn) {
try {
var cleanup = fn();
if (typeof cleanup === 'function') localDestroyHooks.push(cleanup);
} catch (e) { console.error('[stx] onMount error:', e); }
});Every other flush drops it:
| file:line | code |
|---|---|
signals.ts:5588-5592 | cbs.forEach(function(fn) { try { fn(); } catch (e) { … } }); |
signals.ts:6428 | mountCallbacks.forEach(fn => fn()); |
signals.ts:6523-6527 | mountCallbacks.forEach(function(fn) { try { fn(); } catch(e) { … } }); |
signals.ts:7110-7114 | mountCallbacks.forEach(function(fn) { try { fn(); } catch (e) { … } }); |
And the scoped path, which is where a view-level <script client> registration lands, is the same at signals.ts:1665, :3230-3234, :5578-5581, :6515-6518, :7103-7106:
scopeVars.__mountCallbacks.forEach(function(fn) { fn(); });onMount (signals.ts:779-787) routes into scope.__mountCallbacks when a lifecycle scope resolves and into the module-level mountCallbacks otherwise — neither is the cleanup-honouring path.
Prior art
- React:
useEffect(() => { …; return cleanup })— the return is the teardown, on every render path. - Svelte:
onMount(() => () => stop()). - Vue:
effectScope()/onScopeDispose();onUnmountedin every component instance. - Angular:
DestroyRef.onDestroy(fn).
Ask
- Factor the mount-callback invocation into one helper, e.g.
runMountCallbacks(list, destroySink), that captures the return value and registers it as a destroy hook — and route all six flush sites through it (signals.ts:1665, 3230, 5578, 5588, 6428, 6515, 6523, 7103, 7110). - For the scoped path, push the captured cleanup onto
scopeVars.__destroyCallbacksso it is drained by the same sweep that runsonDestroy. - Until (1) lands, warn once when a mount callback returns a function on a path that will discard it, naming the file — a silent drop of a teardown is indistinguishable from a leak.
- Add a test that asserts a returned cleanup runs on each of: full load, SPA fragment swap, full-document swap, and dynamic
:ifunmount.
Related: #1811 (module-path lifecycle queues never drained), #1727 (scope leak on :if/:for unmount).