sveltejs / svelte · Issue No. 18568
Since #16527, every delegated event is stored in a module-level slot (last_propagated_event in internal/client/dom/elements/events.js) to stop Firefox from garbage-collecting the event wrapper mid-propagation (#16522). The slot is never cleared — its only read is the (deliberately redundant, anti-DCE) identity check in handle_event_propagation — so after dispatch completes it keeps the last event strongly referenced indefinitely.
Through event.target, that pins the entire detached subtree of whatever component the user last clicked in. If the user clicks something and that part of the UI is then unmounted (closing a panel/modal/route), the whole removed tree stays reachable until the next delegated event happens to arrive — which in a long-lived idle app can be minutes, hours, or never. We found this via heap-snapshot retainer analysis in a desktop webview app, where a closed pane's ~10k-node DOM tree (plus everything its listener closures referenced) remained live with the retainer path terminating at this slot.
Because the slot's only functional role is the GC pin (the identity check is always true — it's assigned two lines above), clearing it after the dispatch settles cannot change any dispatch/dedup semantics. Dispatch is synchronous within a task; the Firefox GC window is the microtask checkpoints between root listeners, which never span a task boundary. So a zero-delay task scheduled during dispatch runs strictly after the event has finished reaching every delegated root, and clearing there preserves the workaround exactly.
Two data points from validating this against the #16522 reproduction on Firefox 141 (where the wrapper-GC bug reproduces reliably — it appears fixed in Firefox 142+, but ESR 140 is still supported so the pin is still needed):
queueMicrotask clear (for contrast): loses the expando at essentially the same rate as no pin at all — the clear timing genuinely mattersA PR implementing the deferred clear follows shortly; I'll link it here.
Minimal shape (any app; easiest to see in a Chromium-based browser's DevTools):
<script>
let show = $state(true);
</script>
{#if show}
<div>
<!-- a big subtree, e.g. a list of a few thousand items -->
<button onclick={() => (show = false)}>close</button>
</div>
{/if}
target is inside the subtree).{#if} unmounts the subtree.<button> and its whole ancestor tree appear as detached elements; the retainer chain runs through the retained MouseEvent → svelte's last_propagated_event module binding.No response
Reproduces in any browser (the retention is unconditional; only the
*reason* for the pin is Firefox-specific). Observed on svelte 5.56.3
and current main (the slot is never cleared in either).
annoyance
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.