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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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} +