fix: release last_propagated_event after event propagation settles

The strong reference exists to stop Firefox (<= 141) from garbage
collecting the event wrapper mid-propagation, which loses the __root
expando and causes delegated handlers to run twice (#16522). But the
reference was never released, so between user interactions the module
retains the last delegated event - and through event.target the entire
detached subtree of whatever component the user last clicked in, until
the next delegated event happens to arrive.

Dispatch is synchronous within a task, so a task scheduled during
propagation runs strictly after the event has finished reaching every
delegated root (including all microtask checkpoints between listeners).
Clearing the reference there preserves the Firefox workaround exactly
while bounding the retention to milliseconds.

Verified against the #16522 reproduction on Firefox 141 (where the
wrapper GC bug reproduces reliably): pin-with-deferred-clear loses zero
events across 200 clicks under GC pressure, identical to the permanent
pin, while clearing in a microtask instead reintroduces the double
processing at the same rate as having no pin at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/18569/head
rmurphy 2 weeks ago
parent b29d7002ec
commit 4a0d1c7561

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: release `last_propagated_event` after event propagation settles so it no longer retains the last event's target subtree

@ -159,12 +159,15 @@ export function delegate(events) {
}
// used to store the reference to the currently propagated event
// to prevent garbage collection between microtasks in Firefox
// to prevent garbage collection between microtasks in Firefox (<= 141)
// If the event object is GCed too early, the expando __root property
// set on the event object is lost, causing the event delegation
// to process the event twice
let last_propagated_event = null;
// whether a task is already queued to clear `last_propagated_event`
let last_propagated_event_clear_scheduled = false;
/**
* @this {EventTarget}
* @param {Event} event
@ -179,6 +182,21 @@ export function handle_event_propagation(event) {
last_propagated_event = event;
// The reference is only needed while the event can still reach another
// delegated root, i.e. during the current (synchronous) dispatch and its
// microtask checkpoints. Clearing it in a later task preserves the
// Firefox workaround while making sure the slot doesn't retain the last
// event forever — through `event.target` it would otherwise keep the
// entire detached subtree of whatever the user last clicked in alive
// until the next delegated event happens to arrive.
if (!last_propagated_event_clear_scheduled) {
last_propagated_event_clear_scheduled = true;
setTimeout(() => {
last_propagated_event_clear_scheduled = false;
last_propagated_event = null;
});
}
// composedPath contains list of nodes the event has propagated through.
// We check `event_symbol` to skip all nodes below it in case this is a
// parent of the `event_symbol` node, which indicates that there's nested

Loading…
Cancel
Save