From 4a0d1c75619f9140a725cc2df7239b23d5b01ed4 Mon Sep 17 00:00:00 2001 From: rmurphy Date: Mon, 20 Jul 2026 19:40:33 -0400 Subject: [PATCH] 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 --- .changeset/spotty-ducks-refuse.md | 5 +++++ .../internal/client/dom/elements/events.js | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/spotty-ducks-refuse.md diff --git a/.changeset/spotty-ducks-refuse.md b/.changeset/spotty-ducks-refuse.md new file mode 100644 index 0000000000..6faba7e89f --- /dev/null +++ b/.changeset/spotty-ducks-refuse.md @@ -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 diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 45e042a8c6..068c64b9ab 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -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