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}