From 65283bc13a54c41ee96940b559c1a027b8770882 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 20 May 2026 23:49:38 +0200 Subject: [PATCH 01/10] fix: transfer effects when merging batches (#18254) An effect could be gated behind a branch. If we don't defer + transfer them upon merge, the branch would still be marked clean but the effect behind it is dirty but no longer reachable. It's not reachable via mark either because that one only concerns itself with block/async effects, and the branch gating the effect is not guaranteed to be touched by that. Fixes #18249 Little sad side-effect: Since we cannot reliably know _before_ traversal if we have no blocking pending work left (the traversal could mark an if block falsy which contains the last blocker), we gotta undo a performance optimization. --- .changeset/evil-stars-wave.md | 5 +++ .../src/internal/client/reactivity/batch.js | 31 ++++++++++++------- .../async-batch-merge-effect/_config.js | 25 +++++++++++++++ .../async-batch-merge-effect/main.svelte | 25 +++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 .changeset/evil-stars-wave.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte diff --git a/.changeset/evil-stars-wave.md b/.changeset/evil-stars-wave.md new file mode 100644 index 0000000000..b199afe1dd --- /dev/null +++ b/.changeset/evil-stars-wave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: transfer effects when merging batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ae4f5a6dac..2803ffc84c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -289,19 +289,19 @@ export class Batch { } } - // we only reschedule previously-deferred effects if we expect - // to be able to run them after processing the batch - if (!this.#is_deferred()) { - for (const e of this.#dirty_effects) { - this.#maybe_dirty_effects.delete(e); - set_signal_status(e, DIRTY); - this.schedule(e); - } + // We always reschedule previously-deferred effects, not just when + // #is_deferred() is true, because traversing the tree could make + // an if block that contains the last blocking pending effect falsy, + // causing the block to no longer be deferred. + for (const e of this.#dirty_effects) { + this.#maybe_dirty_effects.delete(e); + set_signal_status(e, DIRTY); + this.schedule(e); + } - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); - } + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + this.schedule(e); } const roots = this.#roots; @@ -362,6 +362,10 @@ export class Batch { const earlier_batch = this.#find_earlier_batch(); if (earlier_batch) { + // If this batch collected deferred effects during traversal, they still need + // to run after being merged into the earlier batch. + this.#defer_effects(render_effects); + this.#defer_effects(effects); earlier_batch.#merge(this); return; } @@ -503,6 +507,9 @@ export class Batch { if (d) deferred.promise.then(d.resolve); } + // Mark is not guaranteed not touch these, so we transfer them + this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); + /** * mark all effects that depend on `batch.current`, except the * async effects that we just resolved (TODO unless they depend diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js new file mode 100644 index 0000000000..c2be623de2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, x_y, pop] = target.querySelectorAll('button'); + + x.click(); + await tick(); + x_y.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ' 2 1 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte new file mode 100644 index 0000000000..61efd4fca0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte @@ -0,0 +1,25 @@ + + + + + + +{await push(x)} {await push(y)} + +{#if true} + {y} +{/if} + From 91a42e2ed6bda52205b269d28e70b9d15cd87292 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 21 May 2026 20:25:51 +0200 Subject: [PATCH 02/10] fix: correctly coordinate component-level effects inside async blocks (#18260) While looking at the reproduction in https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414 I immediately got greeted with a runtime error when running it in the playground (weirdly not in the Stackblitz version). The error was that a component expected a binding to be set in onMount, but the timing of onMount was wrong. Turns out it's because our logic to determine whether or not to defer top level effects is flawed. `REACTION_RAN`, which was used previously, is already set if the initialized component is inside an async block. We instead check for `component_context.i` which is set to `true` on `pop()`. --- .changeset/tasty-tires-wait.md | 5 +++++ .../svelte/src/internal/client/reactivity/effects.js | 7 +++++-- .../samples/async-effect-mount-timing/Child.svelte | 6 ++++++ .../samples/async-effect-mount-timing/_config.js | 10 ++++++++++ .../samples/async-effect-mount-timing/main.svelte | 5 +++++ 5 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .changeset/tasty-tires-wait.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte diff --git a/.changeset/tasty-tires-wait.md b/.changeset/tasty-tires-wait.md new file mode 100644 index 0000000000..0f3fd2d671 --- /dev/null +++ b/.changeset/tasty-tires-wait.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly coordinate component-level effects inside async blocks diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 5bdba037b1..1bbda86fa7 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -20,7 +20,6 @@ import { EFFECT, DESTROYED, INERT, - REACTION_RAN, BLOCK_EFFECT, ROOT_EFFECT, EFFECT_TRANSPARENT, @@ -213,7 +212,11 @@ export function user_effect(fn) { // Non-nested `$effect(...)` in a component should be deferred // until the component is mounted var flags = /** @type {Effect} */ (active_effect).f; - var defer = !active_reaction && (flags & BRANCH_EFFECT) !== 0 && (flags & REACTION_RAN) === 0; + var defer = + !active_reaction && + (flags & BRANCH_EFFECT) !== 0 && + component_context !== null && + !component_context.i; if (defer) { // Top-level `$effect(...)` in an unmounted component — defer until mount diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte new file mode 100644 index 0000000000..18856d71e1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte @@ -0,0 +1,6 @@ + + +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js new file mode 100644 index 0000000000..7a6e436825 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // Test that $effect/onMount etc at the top level of components are correctly deferred/coordinated if inside an async block + async test({ assert, logs }) { + await tick(); + assert.deepEqual(logs, [true]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte new file mode 100644 index 0000000000..934d9d7f5b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte @@ -0,0 +1,5 @@ + + + From 4656e6895dde764e99c01d9dd86379df2c881c8a Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 21 May 2026 20:26:09 +0200 Subject: [PATCH 03/10] fix: make unnecessary commit work less likely (#18263) While looking at https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414 and trying to understand how the invariant can happen I noticed that we are not correctly filtering during commit. - we were not ignoring deriveds - we were not comparing the correct values (checking `source.v` instead of the saved value) and not checking if their "is a derived" state differs I'm not able to come up with a test where something fails without these (possibly because it's more about an optimization to do less reruns and not about correctness) fixes, but they do make sense. --- .changeset/tired-socks-brake.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 .changeset/tired-socks-brake.md diff --git a/.changeset/tired-socks-brake.md b/.changeset/tired-socks-brake.md new file mode 100644 index 0000000000..1d302ffa60 --- /dev/null +++ b/.changeset/tired-socks-brake.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: make unnecessary commit work less likely diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2803ffc84c..4952001dd6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -677,8 +677,10 @@ export class Batch { if (!batch.#started) continue; - // Re-run async/block effects that depend on distinct values changed in both batches - var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); + // Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds) + var others = [...batch.current.keys()].filter( + (s) => !(/** @type {[any, boolean]} */ (batch.current.get(s))[1]) && !this.current.has(s) + ); if (others.length === 0) { if (is_earlier) { @@ -718,11 +720,14 @@ export class Batch { } checked = new Map(); - var current_unequal = [...batch.current.keys()].filter((c) => - this.current.has(c) - ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c.v - : true - ); + var current_unequal = [...batch.current] + .filter(([c, v1]) => { + const v2 = this.current.get(c); + if (!v2) return true; + // Either their values are different or one is a derived but not the other + return v2[0] !== v1[0] || v2[1] !== v1[1]; + }) + .map(([c]) => c); if (current_unequal.length > 0) { for (const effect of this.#new_effects) { From a6002b587c9d2f40fe2dc48391c372360abbaf0f Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 21 May 2026 20:26:47 +0200 Subject: [PATCH 04/10] fix: unlink errored and otherwise finished batch (#18264) This fixes the issue in https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414 where an error can create follup-up invariant errors. The batch errors, has no chance to run otherwise (no more pending work) and is therefore "dead". That means we need to unlink it otherwise it's becoming a "zombie" and hangs around, causing unnecessary and potentially buggy (as seen in the reproduction) merge/commit work. I was not able to reduce the reproduction down to a test case that fails without the fix, but it does make a related error test from #17888 work more correctly. --- .changeset/beige-bobcats-eat.md | 5 +++++ packages/svelte/src/internal/client/reactivity/batch.js | 6 ++++++ .../tests/runtime-runes/samples/error-recovery/_config.js | 6 +++--- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changeset/beige-bobcats-eat.md diff --git a/.changeset/beige-bobcats-eat.md b/.changeset/beige-bobcats-eat.md new file mode 100644 index 0000000000..95390c3c2e --- /dev/null +++ b/.changeset/beige-bobcats-eat.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: unlink errored and otherwise finished batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 4952001dd6..b9721b6243 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -326,6 +326,12 @@ export class Batch { this.#traverse(root, effects, render_effects); } catch (e) { reset_all(root); + // If there's no async work left, this branch is now dead and needs + // to be unlinked to not become a zombie that is never cleaned up. + // See https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414 + // for a (non-minimal) reproduction that demonstrates a case where this is necessary + // to not get follow-up false-positives via "batch has scheduled roots" invariant errors. + if (!this.#is_deferred()) this.#unlink(); throw e; } } diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js index 52c1bbd1bf..1f80251806 100644 --- a/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js @@ -2,7 +2,7 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - async test({ assert, target, compileOptions }) { + async test({ assert, target }) { const [toggle, increment] = target.querySelectorAll('button'); flushSync(() => increment.click()); @@ -25,8 +25,8 @@ export default test({ ` -

show: ${compileOptions.experimental?.async ? 'false' : 'true'}

- ` +

show: true

+ ` // show: false would also be fine; this is more about ensuring that things continue to work _somehow_ ); } }); From 078f901f611237d4c6fed16ef894f33572f9ccba Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 21 May 2026 20:27:13 +0200 Subject: [PATCH 05/10] fix: catch rejected promises while merging/committing (#18266) A committing/merging batch can have promises that were rejected (e.g. as obsolete). We gotta "forward" this rejection, too, instead of just the successful promise. At best it results in a uncaught rejection (`async-branch-merge-obsolete`), at worst it means error boundaries are not correctly displayed (`async-later-promise-fails-first`). Solves the reproduction in https://github.com/sveltejs/svelte/issues/18221#issuecomment-4507803845 --- .changeset/true-pigs-go.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 4 +-- .../async-branch-merge-obsolete/_config.js | 17 +++++++++++++ .../async-branch-merge-obsolete/main.svelte | 21 ++++++++++++++++ .../_config.js | 25 +++++++++++++++++++ .../main.svelte | 22 ++++++++++++++++ 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .changeset/true-pigs-go.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte diff --git a/.changeset/true-pigs-go.md b/.changeset/true-pigs-go.md new file mode 100644 index 0000000000..b4900b38fa --- /dev/null +++ b/.changeset/true-pigs-go.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: catch rejected promises while merging/committing diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b9721b6243..82c97cf95c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -510,7 +510,7 @@ export class Batch { for (const [effect, deferred] of batch.async_deriveds) { const d = this.async_deriveds.get(effect); - if (d) deferred.promise.then(d.resolve); + if (d) deferred.promise.then(d.resolve).catch(d.reject); } // Mark is not guaranteed not touch these, so we transfer them @@ -677,7 +677,7 @@ export class Batch { // immediately resolving them? Likely not because of how this.apply() works. for (const [effect, deferred] of this.async_deriveds) { const d = batch.async_deriveds.get(effect); - if (d) deferred.promise.then(d.resolve); + if (d) deferred.promise.then(d.resolve).catch(d.reject); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js new file mode 100644 index 0000000000..faf1ff7f6b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' done'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte new file mode 100644 index 0000000000..4780442293 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte @@ -0,0 +1,21 @@ + + + + +{#if count < 3} + {await push(count)} +{:else} + done +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js new file mode 100644 index 0000000000..5e18c953c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte new file mode 100644 index 0000000000..3293e4ab88 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte @@ -0,0 +1,22 @@ + + + + + + + {await push(count)} + + {#snippet failed()}failed{/snippet} + From 04d408b29d059f131766448265768ce705801aaf Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 21 May 2026 21:53:34 +0200 Subject: [PATCH 06/10] perf: walk composedPath() directly in delegated event propagation (#18268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The propagation walk in `handle_event_propagation` already calls `event.composedPath()` at the start to find the entry index, but then re-derives the same chain step-by-step via `current_target.assignedSlot || current_target.parentNode || .host`. Three property reads per iteration is measurable on the hot event path. Walk the captured `path` array by index instead. ## Notes on behavior `composedPath()` is the spec-compliant snapshot of the dispatch chain: - Same shadow-DOM crossings (slots and shadow roots are included for composed events). - Same `host` traversal (composed-path crosses shadow boundaries when appropriate). - Differs from the previous walk in one edge case: if a handler removes a parent mid-dispatch, the snapshot-based walk continues through the captured chain (matches native browser semantics — the previous `parentNode` walk would have stopped at a null parent). ## Performance Measured in real Chromium on a click through a 30-deep tree with five delegated handlers: **~245k hz → ~277k hz** (~+13%, ~−12% per-event time). ## Test plan - [x] All 6006 runtime tests pass (runtime-runes + runtime-legacy + runtime-browser) - [x] Native shadow-DOM event tests (in runtime-browser) pass unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/event-walk-composed-path.md | 5 +++++ .../src/internal/client/dom/elements/events.js | 15 +++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 .changeset/event-walk-composed-path.md diff --git a/.changeset/event-walk-composed-path.md b/.changeset/event-walk-composed-path.md new file mode 100644 index 0000000000..8b24573930 --- /dev/null +++ b/.changeset/event-walk-composed-path.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: walk composedPath() directly in delegated event propagation diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 5aa41e1c4d..45e042a8c6 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -257,12 +257,7 @@ export function handle_event_propagation(event) { var other_errors = []; while (current_target !== null) { - /** @type {null | Element} */ - var parent_element = - current_target.assignedSlot || - current_target.parentNode || - /** @type {any} */ (current_target).host || - null; + if (current_target === handler_element) break; try { // @ts-expect-error @@ -284,10 +279,10 @@ export function handle_event_propagation(event) { throw_error = error; } } - if (event.cancelBubble || parent_element === handler_element || parent_element === null) { - break; - } - current_target = parent_element; + if (event.cancelBubble) break; + + path_idx++; + current_target = path_idx < path.length ? /** @type {Element} */ (path[path_idx]) : null; } if (throw_error) { From 8b961be0b190078815219e614b739b170e1087b1 Mon Sep 17 00:00:00 2001 From: Puneet Dixit Date: Fri, 22 May 2026 01:54:11 +0530 Subject: [PATCH 07/10] fix: remove raw-text hydration markers (#18269) Fixes #14413. This keeps the temporary raw-text hydration sentinel used by dynamic `` from becoming part of the final DOM. Hydration still gets a marker to advance through for raw-text children, but the marker is removed after the child renderer runs, so `