From faf1e103c53675cbe71860f331d4fedd84c8d612 Mon Sep 17 00:00:00 2001 From: Siddhesh Kabra <146343711+Xsidz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:20:44 +0530 Subject: [PATCH] fix: prevent onoutroend from firing twice when compilerOptions.hmr is true (#18655) Fixes #18440 `hmr()` in `packages/svelte/src/internal/client/dev/hmr.js` wrapped a component in a `block` effect containing a `branch` effect. It forwarded the inner effect's `nodes` object to the outer block: This shared the same reference, meaning `outer_block.nodes === inner_branch.nodes`. When `pause_children` collected transitions for unmounting, Each collected transition had `out(check)` called multiple times, starting multiple animations and firing `outroend` multiple times. Fix by only copying `start`/`end` (the DOM range info the outer block needs) without sharing the transitions array: --- .changeset/fix-hmr-onoutroend-double-fire.md | 5 +++++ .../svelte/src/internal/client/dev/hmr.js | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-hmr-onoutroend-double-fire.md diff --git a/.changeset/fix-hmr-onoutroend-double-fire.md b/.changeset/fix-hmr-onoutroend-double-fire.md new file mode 100644 index 0000000000..0eafbfa731 --- /dev/null +++ b/.changeset/fix-hmr-onoutroend-double-fire.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent onoutroend from firing twice when compilerOptions.hmr is true diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 73dba95f9b..0e988fe66d 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -57,10 +57,21 @@ export function hmr(fn) { if (ran) set_should_intro(true); }); - // Forward the nodes from the inner effect to the outer active effect which would - // get them if the HMR wrapper wasn't there. Do this inside the block not outside - // so that HMR updates to the component will also update the nodes on the active effect. - /** @type {Effect} */ (active_effect).nodes = effect.nodes; + // Forward the start/end DOM nodes from the inner effect to the outer active effect + // which would get them if the HMR wrapper wasn't there. Do this inside the block not + // outside so that HMR updates to the component will also update the nodes on the + // active effect. We copy only start/end, not the full nodes object, so that + // pause_children does not collect transitions from both effects and fire outroend twice. + var inner_nodes = effect.nodes; + if (inner_nodes) { + var ae = /** @type {Effect} */ (active_effect); + if (ae.nodes) { + ae.nodes.start = inner_nodes.start; + ae.nodes.end = inner_nodes.end; + } else { + ae.nodes = { start: inner_nodes.start, end: inner_nodes.end, a: null, t: null }; + } + } }, EFFECT_TRANSPARENT); ran = true;