From 7719a74eefa81a3bee622155ad9318eb997d1081 Mon Sep 17 00:00:00 2001 From: Rohan Santhosh Kumar <181558744+Rohan5commit@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:43:37 +0800 Subject: [PATCH 01/22] chore: fix constructor typo in attributes comment (#18115) ## Summary - Fix the `constructor` typo in the attributes comment. ## Related issue - N/A ## Guideline alignment - Read `CONTRIBUTING.md` and kept this to one focused text-only file change. - No behavior, test, fixture, changeset, or generated-file changes. ## Test plan - `git diff --check` - Not run: comment-only change. Co-authored-by: Codex --- packages/svelte/src/internal/client/dom/elements/attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index a15fc48596..0cec01191a 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -584,7 +584,7 @@ function get_setters(element) { var element_proto = Element.prototype; // Stop at Element, from there on there's only unnecessary setters we're not interested in - // Do not use contructor.name here as that's unreliable in some browser environments + // Do not use constructor.name here as that's unreliable in some browser environments while (element_proto !== proto) { descriptors = get_descriptors(proto); From 9521b9f3dcd4a2c214cad6733717ed78fb046a07 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:47:11 +0200 Subject: [PATCH 02/22] fix: don't rebase just-created batches (#18117) It's possible to rebase just-created batches. Case A: - batch A runs effects - one of these effects writes to a source. This creates a new batch B - an effect _after_ that (still part of "flush effects of batch A") executes a derived. This creates an entry in the `current` Map in batch B - batch A commits after processing batch B (`next_batch` etc logic), batch B is pending. Due to derived being part of batchB.current batch A can wrongfully think these are connected and try to rerun/add effects etc on batch B Case B: - like case A but with an additional await inside a pending snippet Case C: - batch A with source a and b, it flushes effects - one of these effects schedules batch B with b and c scheduling an async effect - batch B is deferred - batch A commits. Due to the a/b/c partial overlap it will needlessly rerun the just scheduled async effect All these cases are wrong. We fix it like this: 1. we call `this.#commit()` _before_ running the new batches, which may stick around due to having pending work, and we don't want to rebase these. This fixes case A and C 2. we capture derived values in `previous_batch` if it exists, because it means we're currently flushing effects, and derived writes belong to that batch and not a new one that might have been scheduled already. This fixes case B Discovered this while working on #18097 --------- Co-authored-by: Rich Harris --- .changeset/easy-singers-retire.md | 5 ++ .../src/internal/client/reactivity/batch.js | 40 +++++++------ .../internal/client/reactivity/deriveds.js | 9 ++- .../async-dont-rebase-new-batch-1/_config.js | 27 +++++++++ .../async-dont-rebase-new-batch-1/main.svelte | 32 ++++++++++ .../async-dont-rebase-new-batch-2/_config.js | 25 ++++++++ .../async-dont-rebase-new-batch-2/main.svelte | 29 ++++++++++ .../async-dont-rebase-new-batch-3/_config.js | 31 ++++++++++ .../async-dont-rebase-new-batch-3/main.svelte | 37 ++++++++++++ .../async-dont-rebase-new-batch-4/_config.js | 58 +++++++++++++++++++ .../async-dont-rebase-new-batch-4/main.svelte | 38 ++++++++++++ 11 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 .changeset/easy-singers-retire.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte diff --git a/.changeset/easy-singers-retire.md b/.changeset/easy-singers-retire.md new file mode 100644 index 0000000000..4420286e13 --- /dev/null +++ b/.changeset/easy-singers-retire.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't rebase just-created batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 7adf3be00c..4239cda04b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -342,6 +342,14 @@ export class Batch { this.#deferred?.resolve(); } + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, + // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. + // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode + // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed + if (async_mode_flag && !batches.has(this)) { + this.#commit(); + } + var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); // Edge case: During traversal new branches might create effects that run immediately and set state, @@ -363,12 +371,6 @@ export class Batch { next_batch.#process(); } - - // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode - // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && !batches.has(this)) { - this.#commit(); - } } /** @@ -575,19 +577,23 @@ 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 : true + this.current.has(c) + ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c.v + : true ); - for (const effect of this.#new_effects) { - if ( - (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && - depends_on(effect, current_unequal, checked) - ) { - if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { - set_signal_status(effect, DIRTY); - batch.schedule(effect); - } else { - batch.#dirty_effects.add(effect); + if (current_unequal.length > 0) { + for (const effect of this.#new_effects) { + if ( + (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && + depends_on(effect, current_unequal, checked) + ) { + if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { + set_signal_status(effect, DIRTY); + batch.schedule(effect); + } else { + batch.#dirty_effects.add(effect); + } } } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5af51449ad..4ae49fecba 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -43,7 +43,7 @@ import { get_error } from '../../shared/dev.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; -import { batch_values, current_batch } from './batch.js'; +import { batch_values, current_batch, previous_batch } from './batch.js'; import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; @@ -399,7 +399,14 @@ export function update_derived(derived) { // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { if (current_batch !== null) { + // We also write to previous_batch because if it exists, it is a sign that we're + // currently in the process of flushing effects. These updates to deriveds may belong + // to the previous batch, not the new one (which can already exist if an earlier + // effect wrote to a source). This can cause bugs when running batch.#commit() later, + // but not adding it to current_batch can, too, so we add it to both. + // See https://github.com/sveltejs/svelte/pull/18117 for more details. current_batch.capture(derived, value, true); + previous_batch?.capture(derived, value, true); } else { derived.v = value; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js new file mode 100644 index 0000000000..fb6f3388c9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte new file mode 100644 index 0000000000..af470363bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte @@ -0,0 +1,32 @@ + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js new file mode 100644 index 0000000000..d8a86f77da --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['delay 0']); + + increment.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2']); + + // This resolve should trigger the async effect only once + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte new file mode 100644 index 0000000000..fc90ae2ba4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte @@ -0,0 +1,29 @@ + + + + +{await delay(a + b + c)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js new file mode 100644 index 0000000000..b430e408c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, shift, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // Resolve the blocking await which shouldn't result in the derived execution capturing + // the new derived value on the new batch, but on the previous batch which is currently flushing + pop.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the non-blocking await which shouldn't result in #commit() rebasing the new batch + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the new batch's await + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte new file mode 100644 index 0000000000..9dec14cd13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte @@ -0,0 +1,37 @@ + + + + + + +{#if count} + + {await delay(count)} + {#snippet pending()}loading{/snippet} + + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js new file mode 100644 index 0000000000..804c1f53bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, unrelated, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + unrelated.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte new file mode 100644 index 0000000000..fdc2447e3e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte @@ -0,0 +1,38 @@ + + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + untrack(() => count_mirror_d); // execute derived; should associate value with the right batch + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} \ No newline at end of file From 90a70cb012733be22858dc4db9a18868159bd9fa Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:18:09 +0200 Subject: [PATCH 03/22] fix: flush eager effects in production (#18107) While working on #18106 I noticed that we're not adding eager effects inside `mark_reactions` when `DEV` is `false`. As a result production build could have `$state.eager` or `$state.pending` not working correctly. ~No test because I can't get Vitest to not run with `DEV` being `true`.~ added a test --- .changeset/flat-shrimps-worry.md | 5 ++++ .../src/internal/client/reactivity/sources.js | 21 ++++++----------- .../samples/async-eager-derived/_config.js | 23 +++++++++++++++++++ .../samples/async-eager-derived/main.svelte | 22 ++++++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 .changeset/flat-shrimps-worry.md create mode 100644 packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte diff --git a/.changeset/flat-shrimps-worry.md b/.changeset/flat-shrimps-worry.md new file mode 100644 index 0000000000..a5f76a0f9d --- /dev/null +++ b/.changeset/flat-shrimps-worry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: flush eager effects in production diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 1831183e6f..a5dd94badf 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -47,7 +47,7 @@ import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; import { set_signal_status, update_derived_status } from './status.js'; -/** @type {Set} */ +/** @type {Set} */ export let eager_effects = new Set(); /** @type {Map} */ @@ -266,12 +266,6 @@ export function flush_eager_effects() { eager_effects_deferred = false; for (const effect of eager_effects) { - // Mark clean inspect-effects as maybe dirty and then check their dirtiness - // instead of just updating the effects - this way we avoid overfiring. - if ((effect.f & CLEAN) !== 0) { - set_signal_status(effect, MAYBE_DIRTY); - } - if (is_dirty(effect)) { update_effect(effect); } @@ -338,12 +332,6 @@ function mark_reactions(signal, status, updated_during_traversal) { // In legacy mode, skip the current effect to prevent infinite loops if (!runes && reaction === active_effect) continue; - // Inspect effects need to run immediately, so that the stack trace makes sense - if (DEV && (flags & EAGER_EFFECT) !== 0) { - eager_effects.add(reaction); - continue; - } - var not_dirty = (flags & DIRTY) === 0; // don't set a DIRTY reaction to MAYBE_DIRTY @@ -351,7 +339,12 @@ function mark_reactions(signal, status, updated_during_traversal) { set_signal_status(reaction, status); } - if ((flags & DERIVED) !== 0) { + if ((flags & EAGER_EFFECT) !== 0) { + // Eager effects need to run immediately: + // - for $inspect so that the stack trace makes sense + // - for $state.eager because they might be without an effect parent + eager_effects.add(/** @type {Effect} */ (reaction)); + } else if ((flags & DERIVED) !== 0) { var derived = /** @type {Derived} */ (reaction); batch_values?.delete(derived); diff --git a/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js new file mode 100644 index 0000000000..043f1610fb --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

true - true

` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

false - false

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte b/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte new file mode 100644 index 0000000000..d1d979126d --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}

From 572444a6961ca73b0972280b9c955c74a02f15a4 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:20:43 +0200 Subject: [PATCH 04/22] fix: ignore false-positive errors of `$inspect` dependencies (#18106) We had logic in place to ignore errors of `$inspect` effects that are about to destroy, but we didn't take into account that we can get these transient errors while checking for `is_dirty` in preparation for running the effect, too. Now effects are marked as dirty in case an error occurs while evaluating their dependencies, which guarantees we will see the error again but we can then handle it properly. Fixes #15741 --------- Co-authored-by: Rich Harris --- .changeset/twelve-cooks-speak.md | 5 +++++ .../svelte/src/internal/client/dev/inspect.js | 2 ++ .../src/internal/client/reactivity/sources.js | 19 ++++++++++++++++- packages/svelte/tests/helpers.js | 2 +- .../inspect-derived-if-destroy/List.svelte | 11 ++++++++++ .../inspect-derived-if-destroy/_config.js | 21 +++++++++++++++++++ .../inspect-derived-if-destroy/main.svelte | 15 +++++++++++++ 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .changeset/twelve-cooks-speak.md create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte diff --git a/.changeset/twelve-cooks-speak.md b/.changeset/twelve-cooks-speak.md new file mode 100644 index 0000000000..d4fcd5c339 --- /dev/null +++ b/.changeset/twelve-cooks-speak.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ignore false-positive errors of `$inspect` dependencies diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index 75b29ce9b1..7a8fa0e963 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -20,6 +20,8 @@ export function inspect(get_value, inspector, show_stack = false) { // in an error (an `$inspect(object.property)` will run before the // `{#if object}...{/if}` that contains it) eager_effect(() => { + error = UNINITIALIZED; + try { var value = get_value(); } catch (e) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index a5dd94badf..f374f6a26b 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -266,7 +266,24 @@ export function flush_eager_effects() { eager_effects_deferred = false; for (const effect of eager_effects) { - if (is_dirty(effect)) { + // Mark clean inspect-effects as maybe dirty and then check their dirtiness + // instead of just updating the effects - this way we avoid overfiring. + if ((effect.f & CLEAN) !== 0) { + set_signal_status(effect, MAYBE_DIRTY); + } + + let dirty; + + try { + dirty = is_dirty(effect); + } catch { + // Dirty-checking can evaluate derived dependencies and throw in cases where + // parent effects are about to destroy this eager effect. Run the effect so + // its own error handling can deal with transient failures. + dirty = true; + } + + if (dirty) { update_effect(effect); } } diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index d0ec8b6e44..52bd47dfae 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -201,7 +201,7 @@ export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true'; * @param {any[]} logs */ export function normalise_inspect_logs(logs) { - /** @type {string[]} */ + /** @type {any[]} */ const normalised = []; for (const log of logs) { diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte new file mode 100644 index 0000000000..c73ac9a99d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte @@ -0,0 +1,11 @@ + + +
    + {#each things as thing} +
  • thing {thing.id}
  • + {/each} +
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js new file mode 100644 index 0000000000..c29022c9dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js @@ -0,0 +1,21 @@ +import { normalise_inspect_logs } from '../../../helpers'; +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, errors, logs }) { + const button = target.querySelector('button'); + + flushSync(() => { + button?.click(); + }); + + assert.htmlEqual(target.innerHTML, ''); + assert.equal(errors.length, 0); + assert.deepEqual(normalise_inspect_logs(logs), [[{ id: 1 }, { id: 2 }]]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte new file mode 100644 index 0000000000..89e09f417b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte @@ -0,0 +1,15 @@ + + +{#if data} + t)} /> +{/if} + + From e65025be58c2e6ed67caf982e8c49a078e521496 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 30 Apr 2026 16:58:06 -0400 Subject: [PATCH 05/22] docs: fix testing docs (#18163) extracted from https://github.com/sveltejs/svelte.dev/pull/1959 --- documentation/docs/07-misc/02-testing.md | 36 ++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md index c1bc69c1c2..0ad82a19b5 100644 --- a/documentation/docs/07-misc/02-testing.md +++ b/documentation/docs/07-misc/02-testing.md @@ -38,6 +38,21 @@ You can now write unit tests for code inside your `.js/.ts` files: ```js /// file: multiplier.svelte.test.js +// @filename: multiplier.svelte.ts +export function multiplier(initial: number, k: number) { + let count = $state(initial); + + return { + get value() { + return count * k; + }, + set: (c: number) => { + count = c; + } + }; +} +// @filename: multiplier.svelte.test.js +// ---cut--- import { flushSync } from 'svelte'; import { expect, test } from 'vitest'; import { multiplier } from './multiplier.svelte.js'; @@ -80,6 +95,16 @@ Since Vitest processes your test files the same way as your source files, you ca ```js /// file: multiplier.svelte.test.js +// @filename: multiplier.svelte.ts +export function multiplier(getCount: () => number, k: number) { + return { + get value() { + return getCount() * k; + } + }; +} +// @filename: multiplier.svelte.test.js +// ---cut--- import { flushSync } from 'svelte'; import { expect, test } from 'vitest'; import { multiplier } from './multiplier.svelte.js'; @@ -115,6 +140,10 @@ If the code being tested uses effects, you need to wrap the test inside `$effect ```js /// file: logger.svelte.test.js +// @filename: logger.svelte.ts +export function logger(fn: () => void) {} +// @filename: logger.svelte.test.js +// ---cut--- import { flushSync } from 'svelte'; import { expect, test } from 'vitest'; import { logger } from './logger.svelte.js'; @@ -213,7 +242,7 @@ test('Component', () => { expect(document.body.innerHTML).toBe(''); // Click the button, then flush the changes so you can synchronously write expectations - document.body.querySelector('button').click(); + document.body.querySelector('button')?.click(); flushSync(); expect(document.body.innerHTML).toBe(''); @@ -226,6 +255,7 @@ test('Component', () => { While the process is very straightforward, it is also low level and somewhat brittle, as the precise structure of your component may change frequently. Tools like [@testing-library/svelte](https://testing-library.com/docs/svelte-testing-library/intro/) can help streamline your tests. The above test could be rewritten like this: ```js +// @errors: 2339 /// file: component.test.js import { render, screen } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; @@ -270,9 +300,9 @@ You can create stories for component variations and test interactions with the [ } }); - + - + { From dc5bd887b50c593033408b7faa079d56f38e74b9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 1 May 2026 15:57:15 -0400 Subject: [PATCH 06/22] fix: resolve stale deriveds with latest value (#18167) While looking into #18162 I found an adjacent bug. Currently, if an async derived resolves in batch 2 before it resolves in batch 1, we reject the promise belonging to batch 1 and by extension the batch itself. This means that any other changes in batch 1 are silently discarded, incorrectly. The fix is almost comically simple: rather than rejecting the earlier promise, we just resolve it with the latest value. I have a hunch that this might also enable us to simplify the rebase logic, though I haven't investigated that in this PR. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/modern-tables-fetch.md | 5 ++++ .../internal/client/reactivity/deriveds.js | 2 +- .../samples/async-stale-derived-3/_config.js | 29 +++++++++++++++++++ .../samples/async-stale-derived-3/main.svelte | 22 ++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/modern-tables-fetch.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte diff --git a/.changeset/modern-tables-fetch.md b/.changeset/modern-tables-fetch.md new file mode 100644 index 0000000000..89543910fa --- /dev/null +++ b/.changeset/modern-tables-fetch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: resolve stale deriveds with latest value diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 4ae49fecba..eb934d96ff 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -232,7 +232,7 @@ export function async_derived(fn, label, location) { for (const [b, d] of deferreds) { deferreds.delete(b); if (b === batch) break; - d.reject(STALE_REACTION); + d.resolve(value); } if (DEV && location !== undefined) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js new file mode 100644 index 0000000000..ff03e0e28e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js @@ -0,0 +1,29 @@ +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(); + + assert.htmlEqual( + target.innerHTML, + `

0 0 0

` + ); + + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + `

2 2 1

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte new file mode 100644 index 0000000000..589ac9ea0e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{await push(count)} {count} {other}

From 89b6a939fe40ac657f27219d29c601fa67202eed Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Tue, 5 May 2026 20:48:50 +0200 Subject: [PATCH 07/22] fix: wrap `Promise.all` in `save` during SSR (#18178) Closes #18168 Not sure if there's a deeper issue in play because the error it's only really there if you also add a title to the component. I think the issue is that with multiple arguments the top level `Promise.all` is not wrapped in `save` and that probably causes a race condition with `title` that sets the context back to `null` in a `finally`. One issue is that now the generated code looks like this ```js const [$$0, $$1] = (await $.save(Promise.all([ (async () => (await $.save(user()))().name)(), (async () => (await $.save(user()))().image)() ])))(); ``` which seems a bit redundant, but I'm not sure if we can get rid of the inner `save` since they are indeed awaiting something. --- .changeset/tough-knives-smell.md | 5 +++++ .../3-transform/server/visitors/shared/utils.js | 4 ++-- .../samples/async-multiple-attrs/_config.js | 8 ++++++++ .../samples/async-multiple-attrs/_expected.html | 1 + .../async-multiple-attrs/_expected_head.html | 1 + .../samples/async-multiple-attrs/main.svelte | 15 +++++++++++++++ 6 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/tough-knives-smell.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte diff --git a/.changeset/tough-knives-smell.md b/.changeset/tough-knives-smell.md new file mode 100644 index 0000000000..7687188c1a --- /dev/null +++ b/.changeset/tough-knives-smell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: wrap `Promise.all` in `save` during SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index a87642bc4c..9b3ac3ad78 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -12,7 +12,7 @@ import { import * as b from '#compiler/builders'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_whitespaces_strict } from '../../../../patterns.js'; -import { has_await_expression } from '../../../../../utils/ast.js'; +import { has_await_expression, save } from '../../../../../utils/ast.js'; import { ExpressionMetadata } from '../../../../nodes.js'; /** Opens an if/each block, so that we can remove nodes in the case of a mismatch */ @@ -360,7 +360,7 @@ export class PromiseOptimiser { return b.const( b.array_pattern(this.expressions.map((_, i) => b.id(`$$${i}`))), - b.await(b.call('Promise.all', promises)) + save(b.call('Promise.all', promises)) ); } diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js new file mode 100644 index 0000000000..aaf40e7a52 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + compileOptions: { + dev: true + } +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html new file mode 100644 index 0000000000..93016d569c --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html new file mode 100644 index 0000000000..96ba2bba28 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html @@ -0,0 +1 @@ +Async multiple attributes \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte new file mode 100644 index 0000000000..f14ccd088b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte @@ -0,0 +1,15 @@ + + + +Async multiple attributes + + +{(await From d4c5a917356a4ef0905681bdd98113c84707db42 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 09:59:39 +0200 Subject: [PATCH 08/22] fix: rethrow error of failed iterable after calling `return()` (#18169) The fix in #17966 wasn't quite right, because we gotta rethrow in case the iterator stopped because of an error. Fixes part of the SvelteKit `query.live` test failure. --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Rich Harris --- .changeset/fresh-stars-grin.md | 5 +++ .../src/internal/client/reactivity/async.js | 25 ++++++++--- .../_config.js | 24 ++++++++++ .../main.svelte | 45 +++++++++++++++++++ .../_config.js | 21 +++++++++ .../main.svelte | 43 ++++++++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 .changeset/fresh-stars-grin.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte diff --git a/.changeset/fresh-stars-grin.md b/.changeset/fresh-stars-grin.md new file mode 100644 index 0000000000..3d56792d1e --- /dev/null +++ b/.changeset/fresh-stars-grin.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: rethrow error of failed iterable after calling `return()` diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 6aea790c36..61fff31f8a 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -213,22 +213,35 @@ export async function* for_await_track_reactivity_loss(iterable) { throw new TypeError('value is not async iterable'); } - /** Whether the completion of the iterator was "normal", meaning it wasn't ended via `break` or a similar method */ - let normal_completion = false; + // eslint-disable-next-line no-useless-assignment + let invoke_return = true; + try { while (true) { const { done, value } = (await track_reactivity_loss(iterator.next()))(); if (done) { - normal_completion = true; + invoke_return = false; break; } var prev = reactivity_loss_tracker; - yield value; + try { + yield value; + } catch (e) { + set_reactivity_loss_tracker(prev); + // If the yield throws, we need to call `return` but not return its value, instead rethrow + if (iterator.return !== undefined) { + (await track_reactivity_loss(iterator.return()))(); + } + throw e; + } set_reactivity_loss_tracker(prev); } + } catch (error) { + invoke_return = false; + throw error; } finally { - // If the iterator had an abrupt completion and `return` is defined on the iterator, call it and return the value - if (!normal_completion && iterator.return !== undefined) { + // If the iterator had an abrupt completion (break) and `return` is defined on the iterator, call it and return the value + if (invoke_return && iterator.return !== undefined) { // eslint-disable-next-line no-unsafe-finally return /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js new file mode 100644 index 0000000000..9785f639cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + html: '

pending

', + async test({ assert, target, warnings }) { + await tick(); + + assert.htmlEqual( + target.innerHTML, + '

number -> number -> number -> return -> body failed -> ended

' + ); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte new file mode 100644 index 0000000000..da7c48642c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte @@ -0,0 +1,45 @@ + + + +

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js new file mode 100644 index 0000000000..9e8a2d8def --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + html: '

pending

', + async test({ assert, target, warnings }) { + await tick(); + + assert.htmlEqual(target.innerHTML, '

number -> number -> next failed -> ended

'); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte new file mode 100644 index 0000000000..ffe2ef93c0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte @@ -0,0 +1,43 @@ + + + +

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
From 5e054574db2bd9f96176626a604046b6db13af09 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:02:57 +0200 Subject: [PATCH 09/22] fix: don't override new current_batch (#18170) This is a regression from #18117 - we moved `this.#commit()` higher up but that means that `current_batch` could be nulled out / overridden through `batch.activate/deactivate` / blocker runs inside `#commit()`. Therefore restore the previous value afterwards. No changest because #18117 is not released yet. Fixes the other part of the failing SvelteKit `query.live` test. --------- Co-authored-by: Rich Harris --- .../src/internal/client/reactivity/batch.js | 13 ++++++-- .../svelte/tests/runtime-legacy/shared.ts | 6 ++-- .../_config.js | 19 ++++++++++++ .../main.svelte | 30 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 4239cda04b..2801445ae7 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -92,6 +92,9 @@ let uid = 1; export class Batch { id = uid++; + /** True as soon as `#process()` was called */ + #started = false; + /** * The current values of any signals that are updated in this batch. * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) @@ -255,6 +258,8 @@ export class Batch { } #process() { + this.#started = true; + if (flush_count++ > 1000) { batches.delete(this); infinite_loop_guard(); @@ -342,6 +347,8 @@ export class Batch { this.#deferred?.resolve(); } + var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode @@ -350,8 +357,6 @@ export class Batch { this.#commit(); } - var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); - // Edge case: During traversal new branches might create effects that run immediately and set state, // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // once more in that case - most of the time this will just clean up dirty branches. @@ -537,6 +542,8 @@ export class Batch { sources.push(source); } + 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)); @@ -722,7 +729,7 @@ export class Batch { if (!is_flushing_sync) { queue_micro_task(() => { - if (!batches.has(batch) || batch.#pending.size > 0) { + if (batch.#started) { // a flushSync happened in the meantime return; } diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 454ae2f766..6f30fb5d98 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -60,6 +60,8 @@ export interface RuntimeTest = Record void; after_test?: () => void; + /** If true, flushSync() will not be called before invoking test() */ + skip_initial_flushSync?: boolean; test?: (args: { variant: 'dom' | 'hydrate'; assert: Assert; @@ -505,7 +507,7 @@ async function run_test_variant( try { if (config.test) { - flushSync(); + if (!config.skip_initial_flushSync) flushSync(); if (variant === 'hydrate' && cwd.includes('async-')) { // wait for pending boundaries to render @@ -543,7 +545,7 @@ async function run_test_variant( } } finally { if (runes) { - unmount(instance); + await unmount(instance); } else { instance.$destroy(); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js new file mode 100644 index 0000000000..922feed515 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that batch.#commit() does not null out a potentially new current_batch +export default test({ + skip_initial_flushSync: true, // test that the initial batch is flushed without an explicit flushSync() call + async test({ assert, target }) { + await tick(); + + const [button] = target.querySelectorAll('button'); + const [updates] = target.querySelectorAll('p'); + + assert.htmlEqual(updates.innerHTML, 'false'); + + button.click(); + await tick(); + assert.htmlEqual(updates.innerHTML, 'true'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte new file mode 100644 index 0000000000..f7dae33b7e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte @@ -0,0 +1,30 @@ + + + + +

{updated}

+ + + {await new Promise(() => {})} + + {#snippet pending()} +

pending

+ {/snippet} +
From aeb6bd088b0f54d3847b244f7066333b28dead3f Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:04:50 +0200 Subject: [PATCH 10/22] fix: reapply context after transforming error during SSR (#18099) Don't have a test for it and no bug report but I stumbled upon this and I'm very certain not restoring context here is wrong since it means the failed snippet rendering gets the wrong context. --------- Co-authored-by: Rich Harris --- .changeset/cruel-boxes-serve.md | 5 +++++ packages/svelte/src/internal/server/renderer.js | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/cruel-boxes-serve.md diff --git a/.changeset/cruel-boxes-serve.md b/.changeset/cruel-boxes-serve.md new file mode 100644 index 0000000000..592cec4d01 --- /dev/null +++ b/.changeset/cruel-boxes-serve.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reapply context after transforming error during SSR diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index d2ab35a1f2..35aac64721 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -715,7 +715,12 @@ export class Renderer { const { context, failed, transformError } = item.#boundary; set_ssr_context(context); - let transformed = await transformError(error); + + let promise = transformError(error); + set_ssr_context(null); + + let transformed = await promise; + set_ssr_context(context); // Render the failed snippet instead of the partial children content const failed_renderer = new Renderer(item.global, item); From 91e1ead773c00eece56d49542c94670aabbc5902 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 6 May 2026 16:16:06 -0400 Subject: [PATCH 11/22] chore: remove unnecessary `increment_pending` calls (#18183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These might have been necessary at one point, but I'm confident they're unnecessary now — `increment_pending` happens (if necessary) inside `flatten`, which is called inside `async` and `deferred_template_effect`, so there's no need to call it inside those functions as well. --- .changeset/public-mammals-float.md | 5 +++++ .../svelte/src/internal/client/dom/blocks/async.js | 6 +----- .../svelte/src/internal/client/reactivity/effects.js | 10 +--------- 3 files changed, 7 insertions(+), 14 deletions(-) create mode 100644 .changeset/public-mammals-float.md diff --git a/.changeset/public-mammals-float.md b/.changeset/public-mammals-float.md new file mode 100644 index 0000000000..d890c9e070 --- /dev/null +++ b/.changeset/public-mammals-float.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: remove unnecessary `increment_pending` calls diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 43af3d8dd3..170529a6b9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,5 +1,5 @@ /** @import { Blocker, TemplateNode, Value } from '#client' */ -import { flatten, increment_pending } from '../../reactivity/async.js'; +import { flatten } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -42,8 +42,6 @@ export function async(node, blockers = [], expressions = [], fn) { return; } - const decrement_pending = increment_pending(); - if (was_hydrating) { var previous_hydrate_node = hydrate_node; set_hydrate_node(end); @@ -64,8 +62,6 @@ export function async(node, blockers = [], expressions = [], fn) { if (was_hydrating) { set_hydrating(false); } - - decrement_pending(); } }); } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 0fad074e6f..5bdba037b1 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -43,7 +43,7 @@ import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; import { Batch, collected_effects, current_batch } from './batch.js'; -import { flatten, increment_pending } from './async.js'; +import { flatten } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; @@ -396,16 +396,8 @@ export function template_effect(fn, sync = [], async = [], blockers = []) { * @param {Blocker[]} blockers */ export function deferred_template_effect(fn, sync = [], async = [], blockers = []) { - if (async.length > 0 || blockers.length > 0) { - var decrement_pending = increment_pending(); - } - flatten(blockers, sync, async, (values) => { create_effect(EFFECT, () => fn(...values.map(get))); - - if (decrement_pending) { - decrement_pending(); - } }); } From 1c150a460fdb67703aeba9eb515c0a30e2348ccb Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:37:46 +0200 Subject: [PATCH 12/22] fix: abort running obsolete async branches (#18118) We shouldn't continue executing async work where we know the surrounding branch is destroyed already, it can leave to noisy "derived inter" warnings or even runtime errors ("cannot stringify symbol" when running a template effect with an uninitialized source). Neither should we warn about waterfalls on an already-destroyed async effect. Fixes #18097 (though strictly speaking that particular instance is also fixed by #18117 which fixes the underlying cause for the reruns; this one is necessary in itself though, as shown by the new test) --------- Co-authored-by: Rich Harris --- .changeset/stupid-baboons-fall.md | 5 +++ .../src/internal/client/reactivity/async.js | 8 +++-- .../internal/client/reactivity/deriveds.js | 2 +- .../Child.svelte | 6 ++++ .../_config.js | 27 ++++++++++++++++ .../main.svelte | 31 +++++++++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .changeset/stupid-baboons-fall.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte diff --git a/.changeset/stupid-baboons-fall.md b/.changeset/stupid-baboons-fall.md new file mode 100644 index 0000000000..66895ad015 --- /dev/null +++ b/.changeset/stupid-baboons-fall.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: abort running obsolete async branches diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 61fff31f8a..5e418d81a1 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -55,14 +55,16 @@ export function flatten(blockers, sync, async, fn) { /** @param {Value[]} values */ function finish(values) { + if ((parent.f & DESTROYED) !== 0) { + return; + } + restore(); try { fn(values); } catch (error) { - if ((parent.f & DESTROYED) === 0) { - invoke_error_boundary(error, parent); - } + invoke_error_boundary(error, parent); } unset_context(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index eb934d96ff..070dfc8ff3 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -239,7 +239,7 @@ export function async_derived(fn, label, location) { recent_async_deriveds.add(signal); setTimeout(() => { - if (recent_async_deriveds.has(signal)) { + if (recent_async_deriveds.has(signal) && (effect.f & DESTROYED) === 0) { w.await_waterfall(/** @type {string} */ (signal.label), location); recent_async_deriveds.delete(signal); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte new file mode 100644 index 0000000000..1d9bdfada2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js new file mode 100644 index 0000000000..83364706e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs, warnings }) { + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, [1, 2]); + + // no await waterfall / inert derived warnings + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte new file mode 100644 index 0000000000..fe01ae457e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte @@ -0,0 +1,31 @@ + + + + + + + {#if count % 2 === 0} + {@const double = count * 2} +

true

+ {await push(count)} {double} + + {:else} +

false

+ + {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
From 4d2b6c61e0259f497c14d5148fe80e16da684ef2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 6 May 2026 18:24:23 -0400 Subject: [PATCH 13/22] chore: make `batch.#pending` a number rather than a map (#18184) there's no reason for this to be a map --- .../src/internal/client/reactivity/batch.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2801445ae7..2d555bb34d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -130,10 +130,9 @@ export class Batch { #fork_commit_callbacks = new Set(); /** - * Async effects that are currently in flight - * @type {Map} + * The number of async effects that are currently in flight */ - #pending = new Map(); + #pending = 0; /** * Async effects that are currently in flight, _not_ inside a pending boundary @@ -327,7 +326,7 @@ export class Batch { reset_branch(e, t); } } else { - if (this.#pending.size === 0) { + if (this.#pending === 0) { batches.delete(this); } @@ -637,8 +636,7 @@ export class Batch { * @param {Effect} effect */ increment(blocking, effect) { - let pending_count = this.#pending.get(effect) ?? 0; - this.#pending.set(effect, pending_count + 1); + this.#pending += 1; if (blocking) { let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; @@ -652,13 +650,7 @@ export class Batch { * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ decrement(blocking, effect, skip) { - let pending_count = this.#pending.get(effect) ?? 0; - - if (pending_count === 1) { - this.#pending.delete(effect); - } else { - this.#pending.set(effect, pending_count - 1); - } + this.#pending -= 1; if (blocking) { let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; From 908c9d031283955629f784a9a9cd523172c10353 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 7 May 2026 04:30:24 -0400 Subject: [PATCH 14/22] fix: leave stale promises to wait for a later resolution, instead of rejecting (#18180) This incorporates some of the fixes and insights from #18177, but gets rid of the `skip` logic. Instead, we differentiate between _stale_ and _obsolete_ promises. A promise is stale if it has been overtaken by a subsequent update, and was rejected with `STALE_REACTION`: ```ts async function search(query: string) { return fetch(`/search?q=${query}`, { signal: getAbortSignal() }).then((r) => r.json()); } ``` In this case, if we start typing `pot`, and then finish typing `potato`, the first promise will eventually resolve with the results for `/search?q=potato`, instead of the batch entering a weird limbo/zombie state. A promise is obsolete if it belongs to a now-destroyed effect, meaning that toggling `show` doesn't result in an accumulation of never-resolving batches: ```svelte {#if show} {await neverResolves()} {/if} ``` Fixes part of https://github.com/sveltejs/kit/issues/15431 --------- Co-authored-by: Simon Holthausen Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/big-webs-sing.md | 5 +++ .../src/internal/client/reactivity/async.js | 17 ++++--- .../src/internal/client/reactivity/batch.js | 19 ++++++-- .../internal/client/reactivity/deriveds.js | 43 ++++++++++-------- .../samples/async-batch-order/_config.js | 30 +++++++++++++ .../samples/async-batch-order/main.svelte | 21 +++++++++ .../samples/async-stale-derived-4/_config.js | 28 ++++++++++++ .../samples/async-stale-derived-4/main.svelte | 21 +++++++++ .../samples/async-stale-derived-5/_config.js | 33 ++++++++++++++ .../samples/async-stale-derived-5/main.svelte | 44 +++++++++++++++++++ .../samples/async-stale-derived-6/_config.js | 34 ++++++++++++++ .../samples/async-stale-derived-6/main.svelte | 21 +++++++++ .../samples/async-stale-derived-7/_config.js | 34 ++++++++++++++ .../samples/async-stale-derived-7/main.svelte | 22 ++++++++++ 14 files changed, 342 insertions(+), 30 deletions(-) create mode 100644 .changeset/big-webs-sing.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte diff --git a/.changeset/big-webs-sing.md b/.changeset/big-webs-sing.md new file mode 100644 index 0000000000..946a41d881 --- /dev/null +++ b/.changeset/big-webs-sing.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: leave stale promises to wait for a later resolution, instead of rejecting diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 5e418d81a1..c1d4cbcd67 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -70,20 +70,23 @@ export function flatten(blockers, sync, async, fn) { unset_context(); } + var decrement_pending = increment_pending(); + // Fast path: blockers but no async expressions if (async.length === 0) { - /** @type {Promise} */ (blocker_promise).then(() => finish(sync.map(d))); + /** @type {Promise} */ (blocker_promise) + .then(() => finish(sync.map(d))) + .finally(decrement_pending); + return; } - var decrement_pending = increment_pending(); - // Full path: has async expressions function run() { Promise.all(async.map((expression) => async_derived(expression))) .then((result) => finish([...sync.map(d), ...result])) .catch((error) => invoke_error_boundary(error, parent)) - .finally(() => decrement_pending()); + .finally(decrement_pending); } if (blocker_promise) { @@ -325,7 +328,7 @@ export function run(thunks) { // wait one more tick, so that template effects are // guaranteed to run before `$effect(...)` .then(() => Promise.resolve()) - .finally(() => decrement_pending()); + .finally(decrement_pending); return blockers; } @@ -349,8 +352,8 @@ export function increment_pending() { boundary.update_pending_count(1, batch); batch.increment(blocking, effect); - return (skip = false) => { + return () => { boundary.update_pending_count(-1, batch); - batch.decrement(blocking, effect, skip); + batch.decrement(blocking, effect); }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2d555bb34d..a106806721 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -110,6 +110,13 @@ export class Batch { */ previous = new Map(); + /** + * Async effects which this batch doesn't take into account anymore when calculating blockers, + * as it has a value for it already. + * @type {Set} + */ + unblocked = new Set(); + /** * When the batch is committed (and the DOM is updated), we need to remove old branches * and append new ones by calling the functions added inside (if/each/key/etc) blocks @@ -200,6 +207,8 @@ export class Batch { #is_blocked() { for (const batch of this.#blockers) { for (const effect of batch.#blocking_pending.keys()) { + if (this.unblocked.has(effect)) continue; + var skipped = false; var e = effect; @@ -647,9 +656,8 @@ export class Batch { /** * @param {boolean} blocking * @param {Effect} effect - * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ - decrement(blocking, effect, skip) { + decrement(blocking, effect) { this.#pending -= 1; if (blocking) { @@ -662,12 +670,15 @@ export class Batch { } } - if (this.#decrement_queued || skip) return; + if (this.#decrement_queued) return; this.#decrement_queued = true; queue_micro_task(() => { this.#decrement_queued = false; - this.flush(); + + if (batches.has(this)) { + this.flush(); + } }); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 070dfc8ff3..794da4f2b0 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -100,6 +100,8 @@ export function derived(fn) { return signal; } +const OBSOLETE = {}; + /** * @template V * @param {() => V | Promise} fn @@ -118,7 +120,7 @@ export function async_derived(fn, label, location) { var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); - if (DEV) signal.label = label; + if (DEV) signal.label = label ?? fn.toString(); // only suspend in async deriveds created on initialisation var should_suspend = !active_reaction; @@ -141,7 +143,13 @@ export function async_derived(fn, label, location) { // If this code is changed at some point, make sure to still access the then property // of fn() to read any signals it might access, so that we track them as dependencies. // We call `unset_context` to undo any `save` calls that happen inside `fn()` - Promise.resolve(fn()).then(d.resolve, d.reject).finally(unset_context); + Promise.resolve(fn()) + .then(d.resolve, (e) => { + // if the promise was rejected by the user, via `getAbortSignal`, then + // wait for a subsequent resolution instead of flushing the batch + if (e !== STALE_REACTION) d.reject(e); + }) + .finally(unset_context); } catch (error) { d.reject(error); unset_context(); @@ -180,15 +188,13 @@ export function async_derived(fn, label, location) { } if (/** @type {Boundary} */ (parent.b).is_rendered()) { - deferreds.get(batch)?.reject(STALE_REACTION); - deferreds.delete(batch); // delete to ensure correct order in Map iteration below + deferreds.get(batch)?.reject(OBSOLETE); } else { // While the boundary is still showing pending, a new run supersedes all older in-flight runs // for this async expression. Cancel eagerly so resolution cannot commit stale values. for (const d of deferreds.values()) { - d.reject(STALE_REACTION); + d.reject(OBSOLETE); } - deferreds.clear(); } deferreds.set(batch, d); @@ -203,16 +209,10 @@ export function async_derived(fn, label, location) { reactivity_loss_tracker = null; } - if (decrement_pending) { - // don't trigger an update if we're only here because - // the promise was superseded before it could resolve - var skip = error === STALE_REACTION; - decrement_pending(skip); - } + decrement_pending?.(); + deferreds.delete(batch); - if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) { - return; - } + if (error === OBSOLETE) return; batch.activate(); @@ -230,9 +230,14 @@ export function async_derived(fn, label, location) { // All prior async derived runs are now stale for (const [b, d] of deferreds) { - deferreds.delete(b); - if (b === batch) break; - d.resolve(value); + if (b.id < batch.id) { + // Don't delete + resolve directly, instead only do that once + // the current batch commits. This way we avoid tearing when + // `b` is rendering through the early resolve while `batch` is + // still pending. + batch.unblocked.add(effect); + batch.oncommit(() => d.resolve(value)); + } } if (DEV && location !== undefined) { @@ -255,7 +260,7 @@ export function async_derived(fn, label, location) { teardown(() => { for (const d of deferreds.values()) { - d.reject(STALE_REACTION); + d.reject(OBSOLETE); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js new file mode 100644 index 0000000000..53cceb9d54 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift, middle] = target.querySelectorAll('button'); + const [div] = target.querySelectorAll('div'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded + await tick(); + assert.htmlEqual(div.innerHTML, '2 2'); + + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '3 3'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte new file mode 100644 index 0000000000..0289380d78 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte @@ -0,0 +1,21 @@ + + +
+ {a} {await delay(a)} + {#if a < 2} + {await delay(a)} + {/if} +
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js new file mode 100644 index 0000000000..e1555c0062 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, hide, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + pop.click(); + await tick(); + hide.click(); // hides the if block, which cancels the pending async inside, which means the batch can complete + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte new file mode 100644 index 0000000000..5ff3263d39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +{await push(count)} +{#if show} + {await push(count)} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js new file mode 100644 index 0000000000..05d92e9df2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1 = 1

fizz: true

buzz: true

` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1 = 1

fizz: true

buzz: true

` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

3 = 3

fizz: true

buzz: false

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte new file mode 100644 index 0000000000..b2a40e65b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte @@ -0,0 +1,44 @@ + + + + + + +

{n} = {await push(n)}

+ +{#if true} +

fizz: {fizz}

+{/if} + +{#if true} +

buzz: {buzz}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js new file mode 100644 index 0000000000..5b951d6c49 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, pop, shift] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte new file mode 100644 index 0000000000..4515e7a488 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js new file mode 100644 index 0000000000..a6f1833d67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, shift_1, pop_1, shift_2] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + // Check that the first batch can still resolve before the second even if one of its async values + // is already superseeded (but the subsequent batch as a whole is still pending). + shift_1.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte new file mode 100644 index 0000000000..fec684c257 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

From fcaa8ce7236069755912df5f7d29c7e5e5c57974 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 7 May 2026 20:34:26 +0200 Subject: [PATCH 15/22] fix: reapply new batch after `#commit` (#18186) Fixes a regression of #18170 (not released yet therefore no changeset). `current_batch` is nulled out if the `#commit` rebases other branches, and that can lead to nullpointers down the line. No test right now but it's part of getting the failing SvelteKit test passing. --- packages/svelte/src/internal/client/reactivity/batch.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a106806721..8f14cb4437 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -363,6 +363,8 @@ export class Batch { // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed if (async_mode_flag && !batches.has(this)) { this.#commit(); + // Rebases can activate other batches or null it out, therefore restore the new one here + current_batch = next_batch; } // Edge case: During traversal new branches might create effects that run immediately and set state, From 9950b22869b084052cee9e708a79d7cc3bd12be8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:08:04 -0400 Subject: [PATCH 16/22] fix: process batches created in effect and immediately flushed (#18194) no changeset because the bug this fixes hasn't been released --- .../src/internal/client/reactivity/batch.js | 21 ++++++---------- .../async-flushsync-in-effect/_config.js | 25 +++++++++++++++++++ .../async-flushsync-in-effect/main.svelte | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 8f14cb4437..418e2a8f1a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -372,12 +372,11 @@ export class Batch { // once more in that case - most of the time this will just clean up dirty branches. if (this.#roots.length > 0) { const batch = (next_batch ??= this); + batches.add(batch); batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } if (next_batch !== null) { - batches.add(next_batch); - if (DEV) { for (const source of this.current.keys()) { /** @type {Set} */ (source_stacks).add(source); @@ -728,20 +727,14 @@ export class Batch { static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); + batches.add(batch); - if (!is_processing) { - batches.add(current_batch); - - if (!is_flushing_sync) { - queue_micro_task(() => { - if (batch.#started) { - // a flushSync happened in the meantime - return; - } - + if (!is_processing && !is_flushing_sync) { + queue_micro_task(() => { + if (!batch.#started) { batch.flush(); - }); - } + } + }); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js new file mode 100644 index 0000000000..59a81afd35 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-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 [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 0'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 1'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte new file mode 100644 index 0000000000..12c4dd578e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte @@ -0,0 +1,25 @@ + + + + +{await push(count)} From e00944ffd1940110e4c1aeb8912fa26858e610bc Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 8 May 2026 18:08:26 +0200 Subject: [PATCH 17/22] fix: correctly compile component member expressions for SSR (#18192) Fixes #18191 --- .changeset/quiet-teams-pick.md | 5 +++++ .../phases/3-transform/server/visitors/Component.js | 6 +++++- .../samples/dynamic-component-member/Icon.svelte | 1 + .../samples/dynamic-component-member/_config.js | 8 ++++++++ .../samples/dynamic-component-member/main.svelte | 8 ++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-teams-pick.md create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md new file mode 100644 index 0000000000..ed046168be --- /dev/null +++ b/.changeset/quiet-teams-pick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly compile component member expressions for SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js index 8e7d7bcdbf..ed202edd3b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js @@ -9,5 +9,9 @@ import { build_inline_component } from './shared/component.js'; * @param {ComponentContext} context */ export function Component(node, context) { - build_inline_component(node, /** @type {Expression} */ (context.visit(b.id(node.name))), context); + build_inline_component( + node, + /** @type {Expression} */ (context.visit(b.member_id(node.name))), + context + ); } diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte new file mode 100644 index 0000000000..77cdcabaf7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte @@ -0,0 +1 @@ +x diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js new file mode 100644 index 0000000000..22412de14d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client', 'server'], + + html: `x`, + ssrHtml: `x` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte new file mode 100644 index 0000000000..b0f95b2a31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte @@ -0,0 +1,8 @@ + + + From 9ffc13d7c3a9360591e1ad45209b10024e3a8333 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:55:29 -0400 Subject: [PATCH 18/22] fix: remove `source.updated` stack traces after `flush` (#18196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noticed that we're not actually doing anything with `source_stacks` — we shadow the module-level declaration in `flush`, which means we just keep appending to it and then clearing a different (and empty) set. As a result, any source that ever gets an `updated` property never gets rid of it. This probably causes a memory leak? Anyway, this fixes it. ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/red-crabs-ring.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 .changeset/red-crabs-ring.md diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md new file mode 100644 index 0000000000..82b53c5464 --- /dev/null +++ b/.changeset/red-crabs-ring.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reset `source.updated` stack traces after `flush` diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 418e2a8f1a..dfa53788f3 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -85,7 +85,9 @@ export let collected_effects = null; export let legacy_updates = null; var flush_count = 0; -var source_stacks = DEV ? new Set() : null; + +/** @type {Set} */ +var source_stacks = new Set(); let uid = 1; @@ -273,6 +275,14 @@ export class Batch { infinite_loop_guard(); } + if (DEV) { + // track all the values that were updated during this flush, + // so that they can be reset afterwards + for (const value of this.current.keys()) { + source_stacks.add(value); + } + } + // we only reschedule previously-deferred effects if we expect // to be able to run them after processing the batch if (!this.#is_deferred()) { @@ -377,12 +387,6 @@ export class Batch { } if (next_batch !== null) { - if (DEV) { - for (const source of this.current.keys()) { - /** @type {Set} */ (source_stacks).add(source); - } - } - next_batch.#process(); } } @@ -481,9 +485,11 @@ export class Batch { } flush() { - var source_stacks = DEV ? new Set() : null; - try { + if (DEV) { + source_stacks.clear(); + } + is_processing = true; current_batch = this; @@ -501,7 +507,7 @@ export class Batch { old_values.clear(); if (DEV) { - for (const source of /** @type {Set} */ (source_stacks)) { + for (const source of source_stacks) { source.updated = null; } } From af5b9724ab31e8570d9c69ac24c009bb0913960a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 13:41:08 -0400 Subject: [PATCH 19/22] chore: flatten `#process()` (#18190) Realised that we can make the logic in `#process()` a little easier to follow by early-returning if a batch is deferred (or blocked) after processing, since nothing that happens after the `if` block applies in that case --- .../src/internal/client/reactivity/batch.js | 36 +++++++------ .../_config.js | 54 +++++++++++++++++++ .../main.svelte | 29 ++++++++++ 3 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index dfa53788f3..d1f3ce08af 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -344,26 +344,28 @@ export class Batch { for (const [e, t] of this.#skipped_branches) { reset_branch(e, t); } - } else { - if (this.#pending === 0) { - batches.delete(this); + + if (updates.length > 0) { + /** @type {Batch} */ (/** @type {unknown} */ (current_batch)).#process(); } - // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + return; + } - // append/remove branches - for (const fn of this.#commit_callbacks) fn(this); - this.#commit_callbacks.clear(); + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); - previous_batch = this; - flush_queued_effects(render_effects); - flush_queued_effects(effects); - previous_batch = null; + // append/remove branches + for (const fn of this.#commit_callbacks) fn(this); + this.#commit_callbacks.clear(); - this.#deferred?.resolve(); - } + previous_batch = this; + flush_queued_effects(render_effects); + flush_queued_effects(effects); + previous_batch = null; + + this.#deferred?.resolve(); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); @@ -371,7 +373,7 @@ export class Batch { // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && !batches.has(this)) { + if (async_mode_flag && this.#pending === 0) { this.#commit(); // Rebases can activate other batches or null it out, therefore restore the new one here current_batch = next_batch; @@ -530,6 +532,8 @@ export class Batch { } #commit() { + batches.delete(this); + // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js new file mode 100644 index 0000000000..d7293f9b70 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null +export default test({ + skip_no_async: true, + + async test({ target, assert }) { + assert.htmlEqual( + target.innerHTML, + `

0

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1

hello 1 ` + ); + + const input = target.querySelector('input'); + ok(input); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

hello 2 ` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

3

hello 3 ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte new file mode 100644 index 0000000000..4abfd3c2f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte @@ -0,0 +1,29 @@ + + + +

{count}

+ +{#if watcherA} + + {#if true} + {await 'hello'} + {/if} + + {$watcherA} + +{:else} + +{/if} From 1688c84be9102dc08318c74e4c80bd0b62abcfdd Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 13 May 2026 00:25:11 +0200 Subject: [PATCH 20/22] chore: allow `null` for `pending` in typings (#18201) Closes #18166 We handle all falsy values at runtime already and for basically every other dom attribute also allow `null`, so no reason not to do this. --- .changeset/fine-bushes-marry.md | 5 +++++ packages/svelte/elements.d.ts | 6 +++--- .../svelte/src/internal/client/dom/blocks/boundary.js | 9 ++++----- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 .changeset/fine-bushes-marry.md diff --git a/.changeset/fine-bushes-marry.md b/.changeset/fine-bushes-marry.md new file mode 100644 index 0000000000..ccba53babe --- /dev/null +++ b/.changeset/fine-bushes-marry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: allow `null` for `pending` in typings diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index f18b7dea98..daa40635b6 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -2067,9 +2067,9 @@ export interface SvelteHTMLElements { }; 'svelte:head': { [name: string]: any }; 'svelte:boundary': { - onerror?: (error: unknown, reset: () => void) => void; - failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>; - pending?: import('svelte').Snippet; + onerror?: ((error: unknown, reset: () => void) => void) | null | undefined; + failed?: import('svelte').Snippet<[error: unknown, reset: () => void]> | null | undefined; + pending?: import('svelte').Snippet | null | undefined; }; [name: string]: { [name: string]: any }; diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 028b82ab92..beaa7d6869 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -35,19 +35,18 @@ import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; -import { Batch, current_batch, previous_batch, schedule_effect } from '../../reactivity/batch.js'; +import { Batch, current_batch } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; import { create_text } from '../operations.js'; import { defer_effect } from '../../reactivity/utils.js'; -import { set_signal_status } from '../../reactivity/status.js'; /** * @typedef {{ - * onerror?: (error: unknown, reset: () => void) => void; - * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; - * pending?: (anchor: Node) => void; + * onerror?: ((error: unknown, reset: () => void) => void) | null; + * failed?: ((anchor: Node, error: () => unknown, reset: () => () => void) => void) | null; + * pending?: ((anchor: Node) => void) | null; * }} BoundaryProps */ From 5122936edb3c14e9a602e579727479b49cbd3239 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 13 May 2026 16:06:56 -0400 Subject: [PATCH 21/22] fix: treat batches as a linked list (#18205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is another attempt to address some of the tricky edge cases that arise when async batches resolve out of order. The idea is this: Essentially, when a batch resolves: 1. we find the latest batch it shares changes with 2. if none exists (either because this is the earliest batch, or because it is independent of any earlier batches): - we commit it: effects are flushed, `oncommit` callbacks are run - we restart any async work in later batches that depends on both the committed values and the later batch's changes 3. otherwise, we don't commit the batch. instead: - we merge the changes from the later batch onto the earlier batch. in some cases this may mean restarting async work on the earlier batch, but we avoid doing so unnecessarily. - we then `#process()` the earlier batch. if it resolves, goto 1 This feels like it ought to work. There are still two failing tests, which I'm currently looking into. Notable changes: - Instead of having a `batches` set, we have a linked list. When a batch resolves, this makes it easy to find the batch that it should be merged into - The `#is_deferred` logic now takes account of skipped effects — there's no need to wait for a promise inside a falsy `if` block (this accounts for the change in the `async-inner-after-outer` test) - We no longer care about `#blockers` I would like to believe that this approach will allow us to simplify and delete some code, for example the `rebase` logic, though that remains to be seen. It also feels like some version of #18035 would be helpful. - closes #18189 - closes #18162 - fixes https://github.com/sveltejs/kit/issues/15431 ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen --- .changeset/shiny-squids-whisper.md | 5 + .../internal/client/dom/blocks/svelte-head.js | 11 +- .../src/internal/client/reactivity/batch.js | 250 +++++++++++++----- .../internal/client/reactivity/deriveds.js | 27 +- .../async-inner-after-outer/_config.js | 12 - .../_config.js | 20 ++ .../main.svelte | 26 ++ .../samples/async-stale-derived-8/_config.js | 20 ++ .../samples/async-stale-derived-8/main.svelte | 23 ++ 9 files changed, 298 insertions(+), 96 deletions(-) create mode 100644 .changeset/shiny-squids-whisper.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte diff --git a/.changeset/shiny-squids-whisper.md b/.changeset/shiny-squids-whisper.md new file mode 100644 index 0000000000..a8d2d7378c --- /dev/null +++ b/.changeset/shiny-squids-whisper.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: replacing async 'blocking' strategy with 'merging' diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 7cab6c3385..5721f7b056 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -1,8 +1,8 @@ /** @import { TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; -import { block } from '../../reactivity/effects.js'; -import { COMMENT_NODE, EFFECT_PRESERVED, HEAD_EFFECT } from '#client/constants'; +import { block, branch } from '../../reactivity/effects.js'; +import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; /** * @param {string} hash @@ -49,9 +49,10 @@ export function head(hash, render_fn) { } try { - // normally a branch is the child of a block and would have the EFFECT_PRESERVED flag, - // but since head blocks don't necessarily only have direct branch children we add it on the block itself - block(() => render_fn(anchor), HEAD_EFFECT | EFFECT_PRESERVED); + block(() => { + var e = branch(() => render_fn(anchor)); + e.f |= HEAD_EFFECT; + }); } finally { if (was_hydrating) { set_hydrating(true); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index d1f3ce08af..d822834324 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -41,8 +41,11 @@ import { legacy_is_updating_store } from './store.js'; import { invariant } from '../../shared/dev.js'; import { log_effect_tree } from '../dev/debug.js'; -/** @type {Set} */ -const batches = new Set(); +/** @type {Batch | null} */ +let first_batch = null; + +/** @type {Batch | null} */ +let last_batch = null; /** @type {Batch | null} */ export let current_batch = null; @@ -94,9 +97,20 @@ let uid = 1; export class Batch { id = uid++; - /** True as soon as `#process()` was called */ + /** True as soon as `#process` was called */ #started = false; + linked = true; + + /** @type {Batch | null} */ + #prev = null; + + /** @type {Batch | null} */ + #next = null; + + /** @type {Map>>} */ + async_deriveds = new Map(); + /** * The current values of any signals that are updated in this batch. * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) @@ -199,33 +213,24 @@ export class Batch { #decrement_queued = false; - /** @type {Set} */ - #blockers = new Set(); - #is_deferred() { - return this.is_fork || this.#blocking_pending.size > 0; - } - - #is_blocked() { - for (const batch of this.#blockers) { - for (const effect of batch.#blocking_pending.keys()) { - if (this.unblocked.has(effect)) continue; + if (this.is_fork) return true; - var skipped = false; - var e = effect; + for (const effect of this.#blocking_pending.keys()) { + var e = effect; + var skipped = false; - while (e.parent !== null) { - if (this.#skipped_branches.has(e)) { - skipped = true; - break; - } - - e = e.parent; + while (e.parent !== null) { + if (this.#skipped_branches.has(e)) { + skipped = true; + break; } - if (!skipped) { - return true; - } + e = e.parent; + } + + if (!skipped) { + return true; } } @@ -271,7 +276,7 @@ export class Batch { this.#started = true; if (flush_count++ > 1000) { - batches.delete(this); + this.#unlink(); infinite_loop_guard(); } @@ -337,7 +342,8 @@ export class Batch { collected_effects = null; legacy_updates = null; - if (this.#is_deferred() || this.#is_blocked()) { + // if the batch has outstanding pending work, stash effects and bail + if (this.#is_deferred()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -352,6 +358,13 @@ export class Batch { return; } + const earlier_batch = this.#find_earlier_batch(); + + if (earlier_batch) { + earlier_batch.#merge(this); + return; + } + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); @@ -369,11 +382,15 @@ export class Batch { var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + if (this.linked && this.#pending === 0) { + this.#unlink(); + } + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && this.#pending === 0) { + if (async_mode_flag && !this.linked) { this.#commit(); // Rebases can activate other batches or null it out, therefore restore the new one here current_batch = next_batch; @@ -383,8 +400,12 @@ export class Batch { // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // once more in that case - most of the time this will just clean up dirty branches. if (this.#roots.length > 0) { - const batch = (next_batch ??= this); - batches.add(batch); + if (next_batch === null) { + next_batch = this; + this.#link(); + } + + const batch = next_batch; batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } @@ -445,6 +466,82 @@ export class Batch { } } + #find_earlier_batch() { + var batch = this.#prev; + + while (batch !== null) { + if (!batch.is_fork) { + // if the batches are connected, break + for (const [value, [, is_derived]] of this.current) { + if (batch.current.has(value) && !is_derived) { + return batch; + } + } + } + + batch = batch.#prev; + } + + return null; + } + + /** + * @param {Batch} batch + */ + #merge(batch) { + for (const [source, value] of batch.current) { + if (!this.previous.has(source) && batch.previous.has(source)) { + this.previous.set(source, batch.previous.get(source)); + } + + this.current.set(source, value); + } + + for (const [effect, deferred] of batch.async_deriveds) { + const d = this.async_deriveds.get(effect); + if (d) deferred.promise.then(d.resolve); + } + + /** + * mark all effects that depend on `batch.current`, except the + * async effects that we just resolved (TODO unless they depend + * on values in this batch that are NOT in the later batch?). + * Through this we also will populate the correct #skipped_branches, + * oncommit callbacks etc, so we don't need to merge them separately. + * @param {Value} value + */ + const mark = (value) => { + var reactions = value.reactions; + if (reactions === null) return; + + for (const reaction of reactions) { + var flags = reaction.f; + + if ((flags & DERIVED) !== 0) { + mark(/** @type {Derived} */ (reaction)); + } else { + var effect = /** @type {Effect} */ (reaction); + + if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) { + this.#maybe_dirty_effects.delete(effect); + set_signal_status(effect, DIRTY); + this.schedule(effect); + } + } + } + }; + + for (const source of this.current.keys()) { + mark(source); + } + + this.oncommit(() => batch.discard()); + batch.#unlink(); + + current_batch = this; + this.#process(); + } + /** * @param {Effect[]} effects */ @@ -521,7 +618,7 @@ export class Batch { this.#discard_callbacks.clear(); this.#fork_commit_callbacks.clear(); - batches.delete(this); + this.#unlink(); } /** @@ -532,13 +629,13 @@ export class Batch { } #commit() { - batches.delete(this); + this.#unlink(); // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more // recent value for a given source - for (const batch of batches) { + for (let batch = first_batch; batch !== null; batch = batch.#next) { var is_earlier = batch.id < this.id; /** @type {Source[]} */ @@ -561,6 +658,15 @@ export class Batch { sources.push(source); } + if (is_earlier) { + // TODO do we need to restart these in some cases, instead of + // 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 (!batch.#started) continue; // Re-run async/block effects that depend on distinct values changed in both batches @@ -638,17 +744,6 @@ export class Batch { batch.deactivate(); } } - - for (const batch of batches) { - if (batch.#blockers.has(this)) { - batch.#blockers.delete(this); - - if (batch.#blockers.size === 0 && !batch.#is_deferred()) { - batch.activate(); - batch.#process(); - } - } - } } /** @@ -687,7 +782,7 @@ export class Batch { queue_micro_task(() => { this.#decrement_queued = false; - if (batches.has(this)) { + if (this.linked) { this.flush(); } }); @@ -737,7 +832,7 @@ export class Batch { static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); - batches.add(batch); + batch.#link(); if (!is_processing && !is_flushing_sync) { queue_micro_task(() => { @@ -752,7 +847,7 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && batches.size === 1)) { + if (!async_mode_flag || (!this.is_fork && this.#prev === null && this.#next === null)) { batch_values = null; return; } @@ -764,28 +859,33 @@ export class Batch { batch_values.set(source, value); } - // ...and undo changes belonging to other batches unless they block this one - for (const batch of batches) { + // ...and undo changes belonging to other batches unless they intersect + for (let batch = first_batch; batch !== null; batch = batch.#next) { if (batch === this || batch.is_fork) continue; - // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset + // If two batches intersect, the latter batch will be merged into the earlier batch, + // and we should treat them as a single set of changes var intersects = false; - var differs = false; if (batch.id < this.id) { for (const [source, [, is_derived]] of batch.current) { - // Derived values don't partake in the blocking mechanism, because a derived could + // Derived values don't partake in the intersection mechanism, because a derived could // be triggered in one batch already but not the other one yet, causing a false-positive if (is_derived) continue; - intersects ||= this.current.has(source); - differs ||= !this.current.has(source); + if (this.current.has(source)) { + intersects = true; + break; + } } } - if (intersects && differs) { - this.#blockers.add(batch); - } else { + // Since the latter batch merges into the earlier (if it resolves before the earlier one), + // we treat the earlier values as "already applied". This way we don't need to rerun async + // effects of the earlier batch in case they are merged. + // As a result you can think of batch_values as having the latest values of all intersecting + // batches up until this batch. + if (!intersects) { for (const [source, previous] of batch.previous) { if (!batch_values.has(source)) { batch_values.set(source, previous); @@ -851,6 +951,36 @@ export class Batch { this.#roots.push(e); } + + #link() { + if (last_batch === null) { + first_batch = last_batch = this; + } else { + last_batch.#next = this; + this.#prev = last_batch; + } + + last_batch = this; + } + + #unlink() { + var prev = this.#prev; + var next = this.#next; + + if (prev === null) { + first_batch = next; + } else { + prev.#next = next; + } + + if (next === null) { + last_batch = prev; + } else { + next.#prev = prev; + } + + this.linked = false; + } } // TODO Svelte@6 think about removing the callback argument. @@ -1234,7 +1364,7 @@ export function fork(fn) { return; } - if (!batches.has(batch)) { + if (!batch.linked) { e.fork_discarded(); } @@ -1280,7 +1410,7 @@ export function fork(fn) { source.wv = increment_write_version(); } - if (!committed && batches.has(batch)) { + if (!committed && batch.linked) { batch.discard(); } } @@ -1291,5 +1421,5 @@ export function fork(fn) { * Forcibly remove all current batches, to prevent cross-talk between tests */ export function clear() { - batches.clear(); + first_batch = last_batch = null; } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 794da4f2b0..83e23dfab6 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -100,7 +100,7 @@ export function derived(fn) { return signal; } -const OBSOLETE = {}; +export const OBSOLETE = Symbol('obsolete'); /** * @template V @@ -125,8 +125,8 @@ export function async_derived(fn, label, location) { // only suspend in async deriveds created on initialisation var should_suspend = !active_reaction; - /** @type {Map>>} */ - var deferreds = new Map(); + /** @type {Set>>} */ + var deferreds = new Set(); async_effect(() => { var effect = /** @type {Effect} */ (active_effect); @@ -188,7 +188,7 @@ export function async_derived(fn, label, location) { } if (/** @type {Boundary} */ (parent.b).is_rendered()) { - deferreds.get(batch)?.reject(OBSOLETE); + batch.async_deriveds.get(effect)?.reject(OBSOLETE); } else { // While the boundary is still showing pending, a new run supersedes all older in-flight runs // for this async expression. Cancel eagerly so resolution cannot commit stale values. @@ -197,7 +197,8 @@ export function async_derived(fn, label, location) { } } - deferreds.set(batch, d); + deferreds.add(d); + batch.async_deriveds.set(effect, d); } /** @@ -210,7 +211,7 @@ export function async_derived(fn, label, location) { } decrement_pending?.(); - deferreds.delete(batch); + deferreds.delete(d); if (error === OBSOLETE) return; @@ -228,18 +229,6 @@ export function async_derived(fn, label, location) { internal_set(signal, value); - // All prior async derived runs are now stale - for (const [b, d] of deferreds) { - if (b.id < batch.id) { - // Don't delete + resolve directly, instead only do that once - // the current batch commits. This way we avoid tearing when - // `b` is rendering through the early resolve while `batch` is - // still pending. - batch.unblocked.add(effect); - batch.oncommit(() => d.resolve(value)); - } - } - if (DEV && location !== undefined) { recent_async_deriveds.add(signal); @@ -259,7 +248,7 @@ export function async_derived(fn, label, location) { }); teardown(() => { - for (const d of deferreds.values()) { + for (const d of deferreds) { d.reject(OBSOLETE); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js index 8905ee4bf5..c12eba7d17 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js @@ -34,18 +34,6 @@ export default test({ shift?.click(); await tick(); - assert.htmlEqual( - target.innerHTML, - ` -

true

- - - ` - ); - - shift?.click(); - await tick(); - assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js new file mode 100644 index 0000000000..c8bc4c986f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte new file mode 100644 index 0000000000..7689af049c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte @@ -0,0 +1,26 @@ + + + + + +{#if count > 0} + + {await push(count)} {count} {other} + {#snippet failed()}boom{/snippet} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js new file mode 100644 index 0000000000..fd0fc0de48 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); // showing nothing here yet would also be ok + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte new file mode 100644 index 0000000000..42b7206b56 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{#if count > 0} + {await push(count)} {count} {other} +{/if} From ef4b97dfabfd7a23b27933e18f7393587c343d66 Mon Sep 17 00:00:00 2001 From: quyentonndbs Date: Thu, 14 May 2026 07:31:42 -0500 Subject: [PATCH 22/22] fix: duplicated "of" in events.js comment (#18217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-line typo fix in `packages/svelte/src/internal/client/dom/elements/events.js`: "removal or moving of of the DOM" → "removal or moving of the DOM". No code/behavior change. --------- Co-authored-by: Kai Tanaka <275430420+quyentonndbs@users.noreply.github.com> Co-authored-by: Rich Harris --- packages/svelte/src/internal/client/dom/elements/events.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index e598a78949..5aa41e1c4d 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -237,9 +237,9 @@ export function handle_event_propagation(event) { }); // This started because of Chromium issue https://chromestatus.com/feature/5128696823545856, - // where removal or moving of of the DOM can cause sync `blur` events to fire, which can cause logic + // where removal or moving of the DOM can cause sync `blur` events to fire, which can cause logic // to run inside the current `active_reaction`, which isn't what we want at all. However, on reflection, - // it's probably best that all event handled by Svelte have this behaviour, as we don't really want + // it's probably best that all events handled by Svelte have this behaviour, as we don't really want // an event handler to run in the context of another reaction or effect. var previous_reaction = active_reaction; var previous_effect = active_effect;