From f549478dd0508074ac2b8c31777e598936e6f8c9 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 21 Oct 2025 01:07:51 -0700 Subject: [PATCH 1/7] fix: guard contents updated before the guard itself (#16930) Fixes #16850, fixes #16775, fixes #16795, fixes #16982 #16631 introduced a bug that results in the effects within guards being evaluated before the guards themselves. This fix makes sure to iterate the block effects in the correct order (top down) --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen --- .changeset/witty-seas-learn.md | 5 +++ .../src/internal/client/reactivity/batch.js | 31 +++++++++++++++---- .../src/internal/client/reactivity/sources.js | 2 +- .../samples/guard-else-effect/_config.js | 20 ++++++++++++ .../samples/guard-else-effect/main.svelte | 18 +++++++++++ .../samples/guard-if-nested/_config.js | 13 ++++++++ .../samples/guard-if-nested/main.svelte | 18 +++++++++++ .../guard-nested-if-pre/Component.svelte | 6 ++++ .../samples/guard-nested-if-pre/_config.js | 13 ++++++++ .../samples/guard-nested-if-pre/main.svelte | 18 +++++++++++ 10 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 .changeset/witty-seas-learn.md create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-else-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-else-effect/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-if-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-if-nested/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/main.svelte diff --git a/.changeset/witty-seas-learn.md b/.changeset/witty-seas-learn.md new file mode 100644 index 0000000000..aa94c7c35f --- /dev/null +++ b/.changeset/witty-seas-learn.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure guards (eg. if, each, key) run before their contents diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b35e16a409..302d48d03f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -561,7 +561,7 @@ function infinite_loop_guard() { } } -/** @type {Effect[] | null} */ +/** @type {Set | null} */ export let eager_block_effects = null; /** @@ -578,7 +578,7 @@ function flush_queued_effects(effects) { var effect = effects[i++]; if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) { - eager_block_effects = []; + eager_block_effects = new Set(); update_effect(effect); @@ -601,15 +601,34 @@ function flush_queued_effects(effects) { // If update_effect() has a flushSync() in it, we may have flushed another flush_queued_effects(), // which already handled this logic and did set eager_block_effects to null. - if (eager_block_effects?.length > 0) { - // TODO this feels incorrect! it gets the tests passing + if (eager_block_effects?.size > 0) { old_values.clear(); for (const e of eager_block_effects) { - update_effect(e); + // Skip eager effects that have already been unmounted + if ((e.f & (DESTROYED | INERT)) !== 0) continue; + + // Run effects in order from ancestor to descendant, else we could run into nullpointers + /** @type {Effect[]} */ + const ordered_effects = [e]; + let ancestor = e.parent; + while (ancestor !== null) { + if (eager_block_effects.has(ancestor)) { + eager_block_effects.delete(ancestor); + ordered_effects.push(ancestor); + } + ancestor = ancestor.parent; + } + + for (let j = ordered_effects.length - 1; j >= 0; j--) { + const e = ordered_effects[j]; + // Skip eager effects that have already been unmounted + if ((e.f & (DESTROYED | INERT)) !== 0) continue; + update_effect(e); + } } - eager_block_effects = []; + eager_block_effects.clear(); } } } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index cd0c28016d..c5dcff9cfb 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -336,7 +336,7 @@ function mark_reactions(signal, status) { } else if (not_dirty) { if ((flags & BLOCK_EFFECT) !== 0) { if (eager_block_effects !== null) { - eager_block_effects.push(/** @type {Effect} */ (reaction)); + eager_block_effects.add(/** @type {Effect} */ (reaction)); } } diff --git a/packages/svelte/tests/runtime-runes/samples/guard-else-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/guard-else-effect/_config.js new file mode 100644 index 0000000000..4e8eec8b16 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-else-effect/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + mode: ['client'], + async test({ target, assert, logs }) { + const button = target.querySelector('button'); + + button?.click(); + flushSync(); + button?.click(); + flushSync(); + button?.click(); + flushSync(); + button?.click(); + flushSync(); + + assert.deepEqual(logs, ['two', 'one', 'two', 'one', 'two']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/guard-else-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/guard-else-effect/main.svelte new file mode 100644 index 0000000000..91fd0442bd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-else-effect/main.svelte @@ -0,0 +1,18 @@ + + + + +{#if v === "one"} +
if1 matched! {console.log('one')}
+{:else if v === "two"} +
if2 matched! {console.log('two')}
+{:else} +
nothing matched {console.log('else')}
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/guard-if-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/guard-if-nested/_config.js new file mode 100644 index 0000000000..881c1545ee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-if-nested/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + mode: ['client'], + async test({ target, assert }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + + assert.equal(target.textContent?.trim(), 'Trigger'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/guard-if-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/guard-if-nested/main.svelte new file mode 100644 index 0000000000..4514bd114e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-if-nested/main.svelte @@ -0,0 +1,18 @@ + + +{#if centerRow?.nested} + {#if centerRow?.nested?.optional != undefined && centerRow.nested.optional > 0} + op: {centerRow.nested.optional}
+ {:else} + req: {centerRow.nested.required}
+ {/if} +{/if} + + diff --git a/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/Component.svelte b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/Component.svelte new file mode 100644 index 0000000000..b7322e7530 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/Component.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/_config.js b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/_config.js new file mode 100644 index 0000000000..9706855fb4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + const button = target.querySelector('button'); + + button?.click(); + flushSync(); + assert.deepEqual(logs, ['pre', 'running b', 'pre', 'pre']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/main.svelte b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/main.svelte new file mode 100644 index 0000000000..4ebb13eca3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/guard-nested-if-pre/main.svelte @@ -0,0 +1,18 @@ + + +{#if p || !p} + {#if p} + + {/if} +{/if} + + From 925dbebdd64467cf677c86594b4764c609349f89 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 12:23:17 -0700 Subject: [PATCH 2/7] add test, fix block resolution --- .../src/internal/client/reactivity/batch.js | 28 ++++----- .../samples/async-block-resolve/_config.js | 63 +++++++++++++++++++ .../samples/async-block-resolve/main.svelte | 36 +++++++++++ 3 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 215b992cc4..f35003db10 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -157,12 +157,7 @@ export class Batch { this.#traverse_effect_tree(root, target); } - // if there is no outstanding async work, commit - if (this.#pending === 0) { - // commit before flushing effects, since that may result in - // another batch being created - this.#commit(); - } + this.#resolve(); if (this.#blocking_pending > 0) { this.#defer_effects(target.effects); @@ -300,8 +295,8 @@ export class Batch { // this can happen if a new batch was created during `flush_effects()` return; } - } else if (this.#pending === 0) { - this.#commit(); + } else { + this.#resolve(); } this.deactivate(); @@ -317,16 +312,19 @@ export class Batch { } } - /** - * Append and remove branches to/from the DOM - */ - #commit() { - for (const fn of this.#callbacks) { - fn(); + #resolve() { + if (this.#blocking_pending === 0) { + // append/remove branches + for (const fn of this.#callbacks) fn(); + this.#callbacks.clear(); } - this.#callbacks.clear(); + if (this.#pending === 0) { + this.#commit(); + } + } + #commit() { // 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/async-block-resolve/_config.js b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js new file mode 100644 index 0000000000..ee403290bc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js @@ -0,0 +1,63 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

even

+

0

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

even

+

0

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

odd

+

loading...

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

odd

+

1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte new file mode 100644 index 0000000000..73fe83889a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte @@ -0,0 +1,36 @@ + + + + + + + {#if await push(count) % 2 === 0} +

even

+ {:else} +

odd

+ {/if} + + {#key count} + +

{await push(count)}

+ + {#snippet pending()} +

loading...

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

loading...

+ {/snippet} +
From bf9065448a8a9ceb0cb044cfcb6a9dd9532bef8c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 12:44:34 -0700 Subject: [PATCH 3/7] bring async-effect-after-await test from defer-effects-in-pending-boundary branch --- .../samples/async-effect-after-await/Child.svelte | 6 +++++- .../samples/async-effect-after-await/_config.js | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte index 682f7a0631..758ebc0004 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte @@ -1,7 +1,11 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js index 81548a25ea..0908b6a9fe 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js @@ -3,7 +3,8 @@ import { test } from '../../test'; export default test({ async test({ assert, logs }) { + assert.deepEqual(logs, []); await tick(); - assert.deepEqual(logs, ['hello']); + assert.deepEqual(logs, ['before', 'after']); } }); From a57868ebce18662a3bb07d7ec71c612692272a37 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:56:18 +0200 Subject: [PATCH 4/7] fix: don't preserve reactivity context across function boundaries (#17002) Fixes #16809 We gotta take into account function boundaries when determining whether or not we're inside a derived (or const). --- .changeset/short-banks-yell.md | 5 ++ .../src/compiler/phases/2-analyze/index.js | 6 +-- .../src/compiler/phases/2-analyze/types.d.ts | 4 +- .../2-analyze/visitors/AwaitExpression.js | 19 ++++--- .../2-analyze/visitors/CallExpression.js | 2 +- .../phases/2-analyze/visitors/ConstTag.js | 4 +- .../2-analyze/visitors/VariableDeclarator.js | 6 --- .../samples/async-in-derived/_config.js | 3 ++ .../_expected/client/index.svelte.js | 52 +++++++++++++++++++ .../_expected/server/index.svelte.js | 40 ++++++++++++++ .../samples/async-in-derived/index.svelte | 21 ++++++++ 11 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 .changeset/short-banks-yell.md create mode 100644 packages/svelte/tests/snapshot/samples/async-in-derived/_config.js create mode 100644 packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte diff --git a/.changeset/short-banks-yell.md b/.changeset/short-banks-yell.md new file mode 100644 index 0000000000..34d5ba66d3 --- /dev/null +++ b/.changeset/short-banks-yell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't preserve reactivity context across function boundaries diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 47fe37c44d..52be997374 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -306,7 +306,7 @@ export function analyze_module(source, options) { fragment: null, parent_element: null, reactive_statement: null, - in_derived: false + derived_function_depth: -1 }, visitors ); @@ -703,7 +703,7 @@ export function analyze_component(root, source, options) { state_fields: new Map(), function_depth: scope.function_depth, reactive_statement: null, - in_derived: false + derived_function_depth: -1 }; walk(/** @type {AST.SvelteNode} */ (ast), state, visitors); @@ -771,7 +771,7 @@ export function analyze_component(root, source, options) { expression: null, state_fields: new Map(), function_depth: scope.function_depth, - in_derived: false + derived_function_depth: -1 }; walk(/** @type {AST.SvelteNode} */ (ast), state, visitors); diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index ae9c5911f6..bad6c7d613 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -29,9 +29,9 @@ export interface AnalysisState { reactive_statement: null | ReactiveStatement; /** - * True if we're directly inside a `$derived(...)` expression (but not `$derived.by(...)`) + * Set when we're inside a `$derived(...)` expression (but not `$derived.by(...)`) or `@const` */ - in_derived: boolean; + derived_function_depth: number; } export type Context = import('zimmerframe').Context< diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index 9018623570..14757af4a3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -15,7 +15,10 @@ export function AwaitExpression(node, context) { // b) awaits that precede other expressions in template or `$derived(...)` if ( tla || - (is_reactive_expression(context.path, context.state.in_derived) && + (is_reactive_expression( + context.path, + context.state.derived_function_depth === context.state.function_depth + ) && !is_last_evaluated_expression(context.path, node)) ) { context.state.analysis.pickled_awaits.add(node); @@ -53,9 +56,7 @@ export function AwaitExpression(node, context) { * @param {boolean} in_derived */ export function is_reactive_expression(path, in_derived) { - if (in_derived) { - return true; - } + if (in_derived) return true; let i = path.length; @@ -67,6 +68,7 @@ export function is_reactive_expression(path, in_derived) { parent.type === 'FunctionExpression' || parent.type === 'FunctionDeclaration' ) { + // No reactive expression found between function and await return false; } @@ -83,11 +85,16 @@ export function is_reactive_expression(path, in_derived) { * @param {AST.SvelteNode[]} path * @param {Expression | SpreadElement | Property} node */ -export function is_last_evaluated_expression(path, node) { +function is_last_evaluated_expression(path, node) { let i = path.length; while (i--) { - const parent = /** @type {Expression | Property | SpreadElement} */ (path[i]); + const parent = path[i]; + + if (parent.type === 'ConstTag') { + // {@const ...} tags are treated as deriveds and its contents should all get the preserve-reactivity treatment + return false; + } // @ts-expect-error we could probably use a neater/more robust mechanism if (parent.metadata) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 76d9cecd9a..4b66abe1d1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -248,7 +248,7 @@ export function CallExpression(node, context) { context.next({ ...context.state, function_depth: context.state.function_depth + 1, - in_derived: true, + derived_function_depth: context.state.function_depth + 1, expression }); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index 5849d828a3..77ea654905 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -38,6 +38,8 @@ export function ConstTag(node, context) { context.visit(declaration.init, { ...context.state, expression: node.metadata.expression, - in_derived: true + // We're treating this like a $derived under the hood + function_depth: context.state.function_depth + 1, + derived_function_depth: context.state.function_depth + 1 }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js index 7a85b4a93a..dfb1d54040 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js @@ -64,12 +64,6 @@ export function VariableDeclarator(node, context) { } } - if (rune === '$derived') { - context.visit(node.id); - context.visit(/** @type {Expression} */ (node.init), { ...context.state, in_derived: true }); - return; - } - if (rune === '$props') { if (node.id.type !== 'ObjectPattern' && node.id.type !== 'Identifier') { e.props_invalid_identifier(node); diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_config.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_config.js new file mode 100644 index 0000000000..2e30bbeb16 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({ compileOptions: { experimental: { async: true } } }); diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js new file mode 100644 index 0000000000..7a97850175 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js @@ -0,0 +1,52 @@ +import 'svelte/internal/disclose-version'; +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/client'; + +export default function Async_in_derived($$anchor, $$props) { + $.push($$props, true); + + $.async_body($$anchor, async ($$anchor) => { + let yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(); + let yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(); + + let no1 = $.derived(async () => { + return await 1; + }); + + let no2 = $.derived(() => async () => { + return await 1; + }); + + if ($.aborted()) return; + + var fragment = $.comment(); + var node = $.first_child(fragment); + + { + var consequent = ($$anchor) => { + $.async_body($$anchor, async ($$anchor) => { + const yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(); + const yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(); + + const no1 = $.derived(() => (async () => { + return await 1; + })()); + + const no2 = $.derived(() => (async () => { + return await 1; + })()); + + if ($.aborted()) return; + }); + }; + + $.if(node, ($$render) => { + if (true) $$render(consequent); + }); + } + + $.append($$anchor, fragment); + }); + + $.pop(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js new file mode 100644 index 0000000000..69eca5a383 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js @@ -0,0 +1,40 @@ +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/server'; + +export default function Async_in_derived($$renderer, $$props) { + $$renderer.component(($$renderer) => { + $$renderer.async(async ($$renderer) => { + let yes1 = (await $.save(1))(); + let yes2 = foo((await $.save(1))()); + + let no1 = (async () => { + return await 1; + })(); + + let no2 = async () => { + return await 1; + }; + + $$renderer.async(async ($$renderer) => { + if (true) { + $$renderer.push(''); + + const yes1 = (await $.save(1))(); + const yes2 = foo((await $.save(1))()); + + const no1 = (async () => { + return await 1; + })(); + + const no2 = (async () => { + return await 1; + })(); + } else { + $$renderer.push(''); + } + }); + + $$renderer.push(``); + }); + }); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte b/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte new file mode 100644 index 0000000000..bda88fd3ae --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte @@ -0,0 +1,21 @@ + + +{#if true} + {@const yes1 = await 1} + {@const yes2 = foo(await 1)} + {@const no1 = (async () => { + return await 1; + })()} + {@const no2 = (async () => { + return await 1; + })()} +{/if} From 14ba41e7fd47f7abbf565ac42e7f7a874bc8c7ce Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 17:12:35 -0700 Subject: [PATCH 5/7] avoid reawakening committed batches --- .../src/internal/client/reactivity/async.js | 2 +- .../src/internal/client/reactivity/batch.js | 3 ++ .../internal/client/reactivity/deriveds.js | 12 +++++- .../samples/async-resolve-stale/_config.js | 4 ++ .../samples/async-resolve-stale/main.svelte | 40 ++++++++++--------- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 24ff4793ea..a308868336 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,5 +1,5 @@ /** @import { Effect, TemplateNode, Value } from '#client' */ -import { DESTROYED } from '#client/constants'; +import { DESTROYED, STALE_REACTION } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 5414dd2a54..91635bd5d2 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -76,6 +76,8 @@ let is_flushing = false; export let is_flushing_sync = false; export class Batch { + committed = false; + /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` @@ -399,6 +401,7 @@ export class Batch { batch_values = previous_batch_values; } + this.committed = true; batches.delete(this); this.#deferred?.resolve(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 1989220abe..06ae0f6d7a 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -127,7 +127,17 @@ export function async_derived(fn, 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).then(unset_context); + Promise.resolve(fn()) + .then(d.resolve, d.reject) + .then(() => { + if (batch === current_batch && batch.committed) { + // if the batch was rejected as stale, we need to cleanup + // after any `$.save(...)` calls inside `fn()` + batch.deactivate(); + } + + unset_context(); + }); } catch (error) { d.reject(error); unset_context(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js index 50bb414afc..c02abb59c6 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js @@ -21,5 +21,9 @@ export default test({ input.dispatchEvent(new Event('input', { bubbles: true })); await macrotask(6); assert.htmlEqual(target.innerHTML, ' 3 | 12'); + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await macrotask(); + assert.htmlEqual(target.innerHTML, ' 4 | '); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte index dc4a157928..2a36942ff2 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte @@ -1,28 +1,32 @@