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 @@ + + +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 @@ + + + + + +