From 5120307f48be1110cba2005e4ed0839f98b2ec9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:09:29 +0000 Subject: [PATCH 1/4] Initial plan From c996279a7c5c26a90d61a7ea21c4e512d271090e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:17:32 +0000 Subject: [PATCH 2/4] Add test case for @const async function with await bug Co-authored-by: dummdidumm <5968653+dummdidumm@users.noreply.github.com> --- .../const-async-function-await/_config.js | 31 ++++++++++++++++ .../const-async-function-await/main.svelte | 37 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js new file mode 100644 index 000000000000..7acb1291d9b0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js @@ -0,0 +1,31 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

Tab 1

`, + + async test({ assert, target }) { + const [btn1, btn2, btn3] = target.querySelectorAll('button'); + + btn2.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + `

Tab 2

` + ); + + btn3.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + `

Tab 3

` + ); + + btn1.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + `

Tab 1

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte new file mode 100644 index 000000000000..279932e82823 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte @@ -0,0 +1,37 @@ + + + + +{#if activeTab == 'tab1'} +

Tab 1

+{:else if activeTab == 'tab2'} +

Tab 2

+{:else if activeTab == 'tab3'} +

Tab 3

+{/if} From 890399ba94318382822aff7646914cd80887e59b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:36:13 +0000 Subject: [PATCH 3/4] Fix: Don't wrap await inside @const async functions with save The issue was that `is_reactive_expression` was returning true for all awaits inside @const tags because `in_derived` was set to true. This caused awaits inside async functions assigned to @const to be incorrectly pickled with $.save(), breaking reactivity. The fix checks if there's a function in the path between the await and any reactive context. If there's a reactive rune call ($derived, $effect, etc.) between the function and the await, it's still considered reactive. Otherwise, it's not. This preserves the correct behavior for $derived with await inside async functions while fixing the @const async function case. Co-authored-by: dummdidumm <5968653+dummdidumm@users.noreply.github.com> --- .../2-analyze/visitors/AwaitExpression.js | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 90186235708a..2464f7157dbf 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -53,10 +53,6 @@ export function AwaitExpression(node, context) { * @param {boolean} in_derived */ export function is_reactive_expression(path, in_derived) { - if (in_derived) { - return true; - } - let i = path.length; while (i--) { @@ -67,6 +63,27 @@ export function is_reactive_expression(path, in_derived) { parent.type === 'FunctionExpression' || parent.type === 'FunctionDeclaration' ) { + // Check if there's a reactive rune call (like $derived) between this function and the await + for (let j = i + 1; j < path.length; j++) { + const node = path[j]; + // @ts-expect-error + if (node.metadata) { + // There's a reactive expression between the function and the await + return true; + } + // Also check for $derived, $effect, etc. calls + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + (node.callee.name === '$derived' || + node.callee.name === '$effect' || + node.callee.name === '$inspect') + ) { + // This is a reactive rune call + return true; + } + } + // No reactive expression found between function and await return false; } @@ -76,7 +93,7 @@ export function is_reactive_expression(path, in_derived) { } } - return false; + return in_derived; } /** From 8903cba2ebdb490e845520f3f913e4abfff98b78 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 21 Oct 2025 20:34:49 +0200 Subject: [PATCH 4/4] fix: don't preserve reactivity context across function boundaries Fixes #15133 --- .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 | 38 +++++--------- .../2-analyze/visitors/CallExpression.js | 2 +- .../phases/2-analyze/visitors/ConstTag.js | 4 +- .../2-analyze/visitors/VariableDeclarator.js | 6 --- .../const-async-function-await/_config.js | 31 ----------- .../const-async-function-await/main.svelte | 37 ------------- .../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 ++++++++ 13 files changed, 144 insertions(+), 105 deletions(-) create mode 100644 .changeset/short-banks-yell.md delete mode 100644 packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte 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 000000000000..34d5ba66d326 --- /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 47fe37c44df9..52be9973748e 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 ae9c5911f64b..bad6c7d6131c 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 2464f7157dbf..14757af4a3c8 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,6 +56,8 @@ export function AwaitExpression(node, context) { * @param {boolean} in_derived */ export function is_reactive_expression(path, in_derived) { + if (in_derived) return true; + let i = path.length; while (i--) { @@ -63,26 +68,6 @@ export function is_reactive_expression(path, in_derived) { parent.type === 'FunctionExpression' || parent.type === 'FunctionDeclaration' ) { - // Check if there's a reactive rune call (like $derived) between this function and the await - for (let j = i + 1; j < path.length; j++) { - const node = path[j]; - // @ts-expect-error - if (node.metadata) { - // There's a reactive expression between the function and the await - return true; - } - // Also check for $derived, $effect, etc. calls - if ( - node.type === 'CallExpression' && - node.callee?.type === 'Identifier' && - (node.callee.name === '$derived' || - node.callee.name === '$effect' || - node.callee.name === '$inspect') - ) { - // This is a reactive rune call - return true; - } - } // No reactive expression found between function and await return false; } @@ -93,18 +78,23 @@ export function is_reactive_expression(path, in_derived) { } } - return in_derived; + return false; } /** * @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 76d9cecd9ab1..4b66abe1d136 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 5849d828a3de..77ea6549054b 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 7a85b4a93aa1..dfb1d54040fd 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/runtime-runes/samples/const-async-function-await/_config.js b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js deleted file mode 100644 index 7acb1291d9b0..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/const-async-function-await/_config.js +++ /dev/null @@ -1,31 +0,0 @@ -import { flushSync } from 'svelte'; -import { test } from '../../test'; - -export default test({ - html: `

Tab 1

`, - - async test({ assert, target }) { - const [btn1, btn2, btn3] = target.querySelectorAll('button'); - - btn2.click(); - flushSync(); - assert.htmlEqual( - target.innerHTML, - `

Tab 2

` - ); - - btn3.click(); - flushSync(); - assert.htmlEqual( - target.innerHTML, - `

Tab 3

` - ); - - btn1.click(); - flushSync(); - assert.htmlEqual( - target.innerHTML, - `

Tab 1

` - ); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte deleted file mode 100644 index 279932e82823..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/const-async-function-await/main.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - - - -{#if activeTab == 'tab1'} -

Tab 1

-{:else if activeTab == 'tab2'} -

Tab 2

-{:else if activeTab == 'tab3'} -

Tab 3

-{/if} 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 000000000000..2e30bbeb1618 --- /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 000000000000..7a97850175a6 --- /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 000000000000..69eca5a38390 --- /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 000000000000..bda88fd3ae21 --- /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}