From d4bd6ad8f3605bed53eb223a3c31769b911d86e1 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:48:25 +0100
Subject: [PATCH 01/92] fix: ensure "is standalone child" is correctly reset
(#17944)
We didn't set this back to `false`, which can lead to wrong results if
the parent happened to have it set to `true`
Fixes #17730
---
.changeset/shy-parks-enjoy.md | 5 +++++
.../phases/3-transform/client/visitors/Fragment.js | 3 ++-
.../async-render-component-hydration/Image.svelte | 5 +++++
.../async-render-component-hydration/Link.svelte | 7 +++++++
.../async-render-component-hydration/_config.js | 14 ++++++++++++++
.../async-render-component-hydration/main.svelte | 11 +++++++++++
6 files changed, 44 insertions(+), 1 deletion(-)
create mode 100644 .changeset/shy-parks-enjoy.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte
diff --git a/.changeset/shy-parks-enjoy.md b/.changeset/shy-parks-enjoy.md
new file mode 100644
index 0000000000..d57577e6f2
--- /dev/null
+++ b/.changeset/shy-parks-enjoy.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure "is standalone child" is correctly reset
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
index be919d380c..ad0c487fb4 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
@@ -63,6 +63,7 @@ export function Fragment(node, context) {
/** @type {ComponentClientTransformState} */
const state = {
...context.state,
+ is_standalone,
init: [],
snippets: [],
consts: [],
@@ -128,7 +129,7 @@ export function Fragment(node, context) {
// no need to create a template, we can just use the existing block's anchor
process_children(trimmed, () => b.id('$$anchor'), false, {
...context,
- state: { ...state, is_standalone }
+ state
});
} else {
/** @type {(is_text: boolean) => Expression} */
diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte
new file mode 100644
index 0000000000..2696aeb8c2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte
new file mode 100644
index 0000000000..527bba1171
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte
@@ -0,0 +1,7 @@
+
+
+
+ {@render children()}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js
new file mode 100644
index 0000000000..6e3ce0df88
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js
@@ -0,0 +1,14 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ mode: ['hydrate'],
+
+ async test({ assert, target }) {
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `card
`
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte
new file mode 100644
index 0000000000..cb411f6e6c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte
@@ -0,0 +1,11 @@
+
+
+
+
card
+
+
From b472171de66fe8290b55f28623df3fc0e9f34aae Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:49:28 +0100
Subject: [PATCH 02/92] fix: ensure `$inspect` after top level await doesn't
break builds (#17943)
Turn empty statements into empty thunks so that `$.run/$$render.run`
don't throw (as they expect functions as input)
Fixes #17514
---
.changeset/clear-birds-invite.md | 5 +++++
.../phases/3-transform/shared/transform-async.js | 3 ++-
.../samples/async-inspect-build/_config.js | 10 ++++++++++
.../samples/async-inspect-build/main.svelte | 7 +++++++
.../_expected/client/index.svelte.js | 2 +-
.../_expected/server/index.svelte.js | 2 +-
6 files changed, 26 insertions(+), 3 deletions(-)
create mode 100644 .changeset/clear-birds-invite.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte
diff --git a/.changeset/clear-birds-invite.md b/.changeset/clear-birds-invite.md
new file mode 100644
index 0000000000..3c1b73d845
--- /dev/null
+++ b/.changeset/clear-birds-invite.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure `$inspect` after top level await doesn't break builds
diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
index 95b9f585d3..5c8f901d7c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
+++ b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
@@ -95,7 +95,8 @@ export function transform_body(instance_body, runner, transform) {
);
if (expression.type === 'EmptyStatement') {
- return null;
+ // Keep indices stable for async sequencing while avoiding array holes in run([...]).
+ return b.thunk(b.void0, false);
}
return expression.type === 'AwaitExpression'
diff --git a/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js
new file mode 100644
index 0000000000..58dd53c762
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js
@@ -0,0 +1,10 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ ssrHtml: 'works',
+ async test({ assert, target }) {
+ await tick();
+ assert.htmlEqual(target.innerHTML, 'works');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte
new file mode 100644
index 0000000000..a138475293
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte
@@ -0,0 +1,7 @@
+
+
+works
diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js
index e8aa8dfa11..668c936e4e 100644
--- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js
@@ -6,7 +6,7 @@ var root = $.from_html(`
`);
export default function Async_top_level_inspect_server($$anchor) {
var data;
- var $$promises = $.run([async () => data = await Promise.resolve(42),,]);
+ var $$promises = $.run([async () => data = await Promise.resolve(42), () => void 0]);
var p = root();
var text = $.child(p, true);
diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js
index cff5f2d569..5c3afc75d7 100644
--- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js
@@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Async_top_level_inspect_server($$renderer) {
var data;
- var $$promises = $$renderer.run([async () => data = await Promise.resolve(42),,]);
+ var $$promises = $$renderer.run([async () => data = await Promise.resolve(42), () => void 0]);
$$renderer.push(``);
$$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(data)));
From 32a48ed174868e6fdf1fd8f078d2e296ed12353a Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Tue, 17 Mar 2026 13:45:22 +0100
Subject: [PATCH 03/92] fix: don't eagerly access not-yet-initialized functions
in template (#17938)
Our "hey this is a thunk invoking a function, let's flatten that" logic
caused a bug where lazily-initialized functions where eagerly referenced
in the template effect. That causes a nullpointer.
Ensuring these variables are always referenced in a closure fixes #17404
---------
Co-authored-by: Rich Harris
---
.changeset/lemon-geese-post.md | 5 +++++
.../3-transform/client/visitors/shared/utils.js | 8 ++++++--
packages/svelte/src/compiler/utils/builders.js | 14 +++++++-------
.../samples/async-late-value-init/_config.js | 9 +++++++++
.../samples/async-late-value-init/main.svelte | 10 ++++++++++
.../_expected/client/main.svelte.js | 2 +-
.../_expected/client/index.svelte.js | 2 +-
7 files changed, 39 insertions(+), 11 deletions(-)
create mode 100644 .changeset/lemon-geese-post.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte
diff --git a/.changeset/lemon-geese-post.md b/.changeset/lemon-geese-post.md
new file mode 100644
index 0000000000..42d01461c5
--- /dev/null
+++ b/.changeset/lemon-geese-post.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't eagerly access not-yet-initialized functions in template
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js
index 7256073096..1f3c7b2256 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js
@@ -82,12 +82,16 @@ export class Memoizer {
async_values() {
if (this.#async.length === 0) return;
- return b.array(this.#async.map((memo) => b.thunk(memo.expression, true)));
+ // use `b.arrow` rather than `b.thunk` so that deferred async/template effects
+ // always read live bindings rather than a possibly stale snapshot.
+ return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true)));
}
sync_values() {
if (this.#sync.length === 0) return;
- return b.array(this.#sync.map((memo) => b.thunk(memo.expression)));
+ // use `b.arrow` rather than `b.thunk` so that deferred async/template effects
+ // always read live bindings rather than a possibly stale snapshot.
+ return b.array(this.#sync.map((memo) => b.arrow([], memo.expression)));
}
}
diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js
index 223676d22f..7508caf3e7 100644
--- a/packages/svelte/src/compiler/utils/builders.js
+++ b/packages/svelte/src/compiler/utils/builders.js
@@ -36,6 +36,13 @@ export function assignment_pattern(left, right) {
* @returns {ESTree.ArrowFunctionExpression}
*/
export function arrow(params, body, async = false) {
+ // optimize `async () => await x()`, but not `async () => await x(await y)`
+ if (async && body.type === 'AwaitExpression') {
+ if (!has_await_expression(body.argument)) {
+ return arrow(params, body.argument);
+ }
+ }
+
return {
type: 'ArrowFunctionExpression',
params,
@@ -462,13 +469,6 @@ export function thunk(expression, async = false) {
* @returns {ESTree.Expression}
*/
export function unthunk(expression) {
- // optimize `async () => await x()`, but not `async () => await x(await y)`
- if (expression.async && expression.body.type === 'AwaitExpression') {
- if (!has_await_expression(expression.body.argument)) {
- return unthunk(arrow(expression.params, expression.body.argument));
- }
- }
-
if (
expression.async === false &&
expression.body.type === 'CallExpression' &&
diff --git a/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js
new file mode 100644
index 0000000000..efc8fe565d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js
@@ -0,0 +1,9 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ assert.htmlEqual(target.innerHTML, 'aaa 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte
new file mode 100644
index 0000000000..daa539a9b2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte
@@ -0,0 +1,10 @@
+
+
+{name}
+{aa()}
diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js
index d84b674f88..eafadfc2ea 100644
--- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js
@@ -32,7 +32,7 @@ export default function Main($$anchor) {
$.set_attribute(div_1, 'foobar', $0);
$.set_attribute(svg_1, 'viewBox', $1);
},
- [y, y]
+ [() => y(), () => y()]
);
$.append($$anchor, fragment);
diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js
index 464435cb0a..4148a546c8 100644
--- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js
@@ -19,6 +19,6 @@ export default function Text_nodes_deriveds($$anchor) {
var text = $.child(p);
$.reset(p);
- $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]);
+ $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [() => text1(), () => text2()]);
$.append($$anchor, p);
}
\ No newline at end of file
From 1cd06451aff585a0d654bfd39c1abb7c26c239e2 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 17 Mar 2026 09:52:06 -0400
Subject: [PATCH 04/92] fix: remove nodes in boundary when work is pending and
HMR is active (#17932)
Fixes
https://github.com/sveltejs/svelte/issues/17918#issuecomment-4054067024.
The issue here was that in `boundary.#render`, if `this.#pending_count >
0` we yoink the content out of the DOM so we can replace it with the
`pending` fragment. This works by taking everything from
`effect.nodes.start` to `effect.nodes.end` and putting it in a
`DocumentFragment`.
With HMR, that doesn't work, because the effect with the nodes is buried
inside the HMR effect. This fixes it.
Draft because I'd like to try this out in a few more places before
merging.
---
.changeset/tangy-women-shout.md | 5 +++++
packages/svelte/src/internal/client/dev/hmr.js | 13 +++++++++++++
2 files changed, 18 insertions(+)
create mode 100644 .changeset/tangy-women-shout.md
diff --git a/.changeset/tangy-women-shout.md b/.changeset/tangy-women-shout.md
new file mode 100644
index 0000000000..f62d799d93
--- /dev/null
+++ b/.changeset/tangy-women-shout.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: remove nodes in boundary when work is pending and HMR is active
diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js
index 9fa4e6ccbd..071901aafc 100644
--- a/packages/svelte/src/internal/client/dev/hmr.js
+++ b/packages/svelte/src/internal/client/dev/hmr.js
@@ -6,6 +6,8 @@ import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set, source } from '../reactivity/sources.js';
import { set_should_intro } from '../render.js';
import { get } from '../runtime.js';
+import { assign_nodes } from '../dom/template.js';
+import { create_comment } from '../dom/operations.js';
/**
* @template {(anchor: Comment, props: any) => any} Component
@@ -27,6 +29,13 @@ export function hmr(fn) {
let ran = false;
+ // Surround the wrapped effects with comments and assign the nodes
+ // on the wrapping effects so the parent can properly do DOM operations.
+ let start = create_comment();
+ let end = create_comment();
+
+ anchor.before(start);
+
block(() => {
if (component === (component = get(current))) {
return;
@@ -61,6 +70,10 @@ export function hmr(fn) {
anchor = hydrate_node;
}
+ anchor.before(end);
+
+ assign_nodes(start, end);
+
return instance;
}
From f081a6c3b1ca3eade9f870325ebdd892dfa4276f Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Tue, 17 Mar 2026 14:53:29 +0100
Subject: [PATCH 05/92] fix: resume inert effects when they come from offscreen
(#17942)
The offscreen branch was missing the "resume inert effects" logic that
was just below; it never reached that because of the early continue.
Fixes #17851
---
.changeset/fair-hands-relate.md | 5 +++
.../src/internal/client/dom/blocks/each.js | 16 +++++-----
.../async-each-await-stale-rows/_config.js | 32 +++++++++++++++++++
.../async-each-await-stale-rows/main.svelte | 28 ++++++++++++++++
4 files changed, 73 insertions(+), 8 deletions(-)
create mode 100644 .changeset/fair-hands-relate.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte
diff --git a/.changeset/fair-hands-relate.md b/.changeset/fair-hands-relate.md
new file mode 100644
index 0000000000..fd7b3585ad
--- /dev/null
+++ b/.changeset/fair-hands-relate.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: resume inert effects when they come from offscreen
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index b248ce5544..3acdf80d84 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -480,6 +480,14 @@ function reconcile(state, array, anchor, flags, get_key) {
}
}
+ if ((effect.f & INERT) !== 0) {
+ resume_effect(effect);
+ if (is_animated) {
+ effect.nodes?.a?.unfix();
+ (to_animate ??= new Set()).delete(effect);
+ }
+ }
+
if ((effect.f & EFFECT_OFFSCREEN) !== 0) {
effect.f ^= EFFECT_OFFSCREEN;
@@ -508,14 +516,6 @@ function reconcile(state, array, anchor, flags, get_key) {
}
}
- if ((effect.f & INERT) !== 0) {
- resume_effect(effect);
- if (is_animated) {
- effect.nodes?.a?.unfix();
- (to_animate ??= new Set()).delete(effect);
- }
- }
-
if (effect !== current) {
if (seen !== undefined && seen.has(effect)) {
if (matched.length < stashed.length) {
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js
new file mode 100644
index 0000000000..7025e4000e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js
@@ -0,0 +1,32 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const spam = /** @type {HTMLButtonElement} */ (target.querySelector('button.spam'));
+ const resolve = /** @type {HTMLButtonElement} */ (target.querySelector('button.resolve'));
+
+ resolve.click();
+ await tick();
+
+ for (let i = 0; i < 5; i += 1) {
+ spam.click();
+ await tick();
+ }
+
+ for (let i = 0; i < 5; i += 1) {
+ resolve.click();
+ await tick();
+ }
+
+ assert.equal(target.querySelectorAll('div').length, 1);
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ Spam
+ Resolve
+ 5
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte
new file mode 100644
index 0000000000..aeed4fa306
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte
@@ -0,0 +1,28 @@
+
+
+Spam
+ resolvers.shift()?.()}>Resolve
+
+
+ {#each [value.id] as s (s)}
+ {await wait()}
+ {s}
+ {/each}
+
+ {#snippet pending()}
+ pending
+ {/snippet}
+
From 6a303c3638b351386dfc6523db5def622428ce62 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 10:23:19 -0400
Subject: [PATCH 06/92] Version Packages (#17936)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.53.13
### Patch Changes
- fix: ensure `$inspect` after top level await doesn't break builds
([#17943](https://github.com/sveltejs/svelte/pull/17943))
- fix: resume inert effects when they come from offscreen
([#17942](https://github.com/sveltejs/svelte/pull/17942))
- fix: don't eagerly access not-yet-initialized functions in template
([#17938](https://github.com/sveltejs/svelte/pull/17938))
- fix: discard batches made obsolete by commit
([#17934](https://github.com/sveltejs/svelte/pull/17934))
- fix: ensure "is standalone child" is correctly reset
([#17944](https://github.com/sveltejs/svelte/pull/17944))
- fix: remove nodes in boundary when work is pending and HMR is active
([#17932](https://github.com/sveltejs/svelte/pull/17932))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/clear-birds-invite.md | 5 -----
.changeset/fair-hands-relate.md | 5 -----
.changeset/lemon-geese-post.md | 5 -----
.changeset/modern-towns-call.md | 5 -----
.changeset/shy-parks-enjoy.md | 5 -----
.changeset/tangy-women-shout.md | 5 -----
packages/svelte/CHANGELOG.md | 16 ++++++++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
9 files changed, 18 insertions(+), 32 deletions(-)
delete mode 100644 .changeset/clear-birds-invite.md
delete mode 100644 .changeset/fair-hands-relate.md
delete mode 100644 .changeset/lemon-geese-post.md
delete mode 100644 .changeset/modern-towns-call.md
delete mode 100644 .changeset/shy-parks-enjoy.md
delete mode 100644 .changeset/tangy-women-shout.md
diff --git a/.changeset/clear-birds-invite.md b/.changeset/clear-birds-invite.md
deleted file mode 100644
index 3c1b73d845..0000000000
--- a/.changeset/clear-birds-invite.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: ensure `$inspect` after top level await doesn't break builds
diff --git a/.changeset/fair-hands-relate.md b/.changeset/fair-hands-relate.md
deleted file mode 100644
index fd7b3585ad..0000000000
--- a/.changeset/fair-hands-relate.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: resume inert effects when they come from offscreen
diff --git a/.changeset/lemon-geese-post.md b/.changeset/lemon-geese-post.md
deleted file mode 100644
index 42d01461c5..0000000000
--- a/.changeset/lemon-geese-post.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: don't eagerly access not-yet-initialized functions in template
diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md
deleted file mode 100644
index 77ca8e9185..0000000000
--- a/.changeset/modern-towns-call.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: discard batches made obsolete by commit
diff --git a/.changeset/shy-parks-enjoy.md b/.changeset/shy-parks-enjoy.md
deleted file mode 100644
index d57577e6f2..0000000000
--- a/.changeset/shy-parks-enjoy.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: ensure "is standalone child" is correctly reset
diff --git a/.changeset/tangy-women-shout.md b/.changeset/tangy-women-shout.md
deleted file mode 100644
index f62d799d93..0000000000
--- a/.changeset/tangy-women-shout.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: remove nodes in boundary when work is pending and HMR is active
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 3ca0db8dec..a22b7b2174 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,21 @@
# svelte
+## 5.53.13
+
+### Patch Changes
+
+- fix: ensure `$inspect` after top level await doesn't break builds ([#17943](https://github.com/sveltejs/svelte/pull/17943))
+
+- fix: resume inert effects when they come from offscreen ([#17942](https://github.com/sveltejs/svelte/pull/17942))
+
+- fix: don't eagerly access not-yet-initialized functions in template ([#17938](https://github.com/sveltejs/svelte/pull/17938))
+
+- fix: discard batches made obsolete by commit ([#17934](https://github.com/sveltejs/svelte/pull/17934))
+
+- fix: ensure "is standalone child" is correctly reset ([#17944](https://github.com/sveltejs/svelte/pull/17944))
+
+- fix: remove nodes in boundary when work is pending and HMR is active ([#17932](https://github.com/sveltejs/svelte/pull/17932))
+
## 5.53.12
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 49c53a26eb..f8bd216073 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.53.12",
+ "version": "5.53.13",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 5045430cee..a10453ad9f 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.53.12';
+export const VERSION = '5.53.13';
export const PUBLIC_VERSION = '5';
From 5faf102782b6efc60df4290ad2858650594f1ff2 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 17 Mar 2026 16:06:37 -0400
Subject: [PATCH 07/92] fix: reinstate reactivity loss tracking (#17801)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We commented out this code in #17038 because it was broken. I suspect it
was broken because we weren't correctly calling `unset_context` inside
`run`, leading to false positives — this is now fixed, and as such I
_think_ we can safely reinstate it.
One small change — I got rid of the `was_read` check. I assume this
existed to prevent duplicate warnings, but it actually causes false
negatives in the case where you read a signal while the reaction is
being tracked then again while it's untracked.
### 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.
- [ ] 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: Tee Ming
---
.changeset/twelve-stars-serve.md | 5 ++++
.../src/internal/client/reactivity/async.js | 12 ++++----
.../internal/client/reactivity/deriveds.js | 29 ++++++++++++++-----
.../svelte/src/internal/client/runtime.js | 28 +++++++++---------
.../_config.js | 17 ++++-------
.../samples/async-reactivity-loss/_config.js | 3 --
6 files changed, 53 insertions(+), 41 deletions(-)
create mode 100644 .changeset/twelve-stars-serve.md
diff --git a/.changeset/twelve-stars-serve.md b/.changeset/twelve-stars-serve.md
new file mode 100644
index 0000000000..6a4d7ecfa7
--- /dev/null
+++ b/.changeset/twelve-stars-serve.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reinstate reactivity loss tracking
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index f8f5fec290..9b24400840 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -19,10 +19,10 @@ import {
import { Batch, current_batch } from './batch.js';
import {
async_derived,
- current_async_effect,
+ reactivity_loss_tracker,
derived,
derived_safe_equal,
- set_from_async_derived
+ set_reactivity_loss_tracker
} from './deriveds.js';
import { aborted } from './effects.js';
@@ -131,7 +131,7 @@ export function capture() {
}
if (DEV) {
- set_from_async_derived(null);
+ set_reactivity_loss_tracker(null);
set_dev_stack(previous_dev_stack);
}
};
@@ -163,11 +163,11 @@ export async function save(promise) {
* @returns {Promise<() => T>}
*/
export async function track_reactivity_loss(promise) {
- var previous_async_effect = current_async_effect;
+ var previous_async_effect = reactivity_loss_tracker;
var value = await promise;
return () => {
- set_from_async_derived(previous_async_effect);
+ set_reactivity_loss_tracker(previous_async_effect);
return value;
};
}
@@ -224,7 +224,7 @@ export function unset_context(deactivate_batch = true) {
if (deactivate_batch) current_batch?.deactivate();
if (DEV) {
- set_from_async_derived(null);
+ set_reactivity_loss_tracker(null);
set_dev_stack(null);
}
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index aed55f7fba..7b932a8356 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -45,12 +45,16 @@ 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';
-/** @type {Effect | null} */
-export let current_async_effect = null;
+/**
+ * This allows us to track 'reactivity loss' that occurs when signals
+ * are read after a non-context-restoring `await`. Dev-only
+ * @type {{ effect: Effect, warned: boolean } | null}
+ */
+export let reactivity_loss_tracker = null;
-/** @param {Effect | null} v */
-export function set_from_async_derived(v) {
- current_async_effect = v;
+/** @param {{ effect: Effect, warned: boolean } | null} v */
+export function set_reactivity_loss_tracker(v) {
+ reactivity_loss_tracker = v;
}
export const recent_async_deriveds = new Set();
@@ -124,7 +128,12 @@ export function async_derived(fn, label, location) {
var deferreds = new Map();
async_effect(() => {
- if (DEV) current_async_effect = active_effect;
+ if (DEV) {
+ reactivity_loss_tracker = {
+ effect: /** @type {Effect} */ (active_effect),
+ warned: false
+ };
+ }
var effect = /** @type {Effect} */ (active_effect);
@@ -142,7 +151,9 @@ export function async_derived(fn, label, location) {
unset_context();
}
- if (DEV) current_async_effect = null;
+ if (DEV) {
+ reactivity_loss_tracker = null;
+ }
var batch = /** @type {Batch} */ (current_batch);
@@ -174,7 +185,9 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
- if (DEV) current_async_effect = null;
+ if (DEV) {
+ reactivity_loss_tracker = null;
+ }
if (decrement_pending) {
// don't trigger an update if we're only here because
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 3260c24b8c..906d68fbf0 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -27,7 +27,7 @@ import {
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
- destroy_derived_effects,
+ reactivity_loss_tracker,
execute_derived,
freeze_derived_effects,
recent_async_deriveds,
@@ -58,6 +58,7 @@ import { UNINITIALIZED } from '../../constants.js';
import { captured_signals } from './legacy.js';
import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js';
+import * as w from './warnings.js';
let is_updating_effect = false;
@@ -568,19 +569,20 @@ export function get(signal) {
}
if (DEV) {
- // TODO reinstate this, but make it actually work
- // if (current_async_effect) {
- // var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0;
- // var was_read = current_async_effect.deps?.includes(signal);
+ if (
+ !untracking &&
+ reactivity_loss_tracker &&
+ !reactivity_loss_tracker.warned &&
+ (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0
+ ) {
+ reactivity_loss_tracker.warned = true;
- // if (!tracking && !untracking && !was_read) {
- // w.await_reactivity_loss(/** @type {string} */ (signal.label));
+ w.await_reactivity_loss(/** @type {string} */ (signal.label));
- // var trace = get_error('traced at');
- // // eslint-disable-next-line no-console
- // if (trace) console.warn(trace);
- // }
- // }
+ var trace = get_error('traced at');
+ // eslint-disable-next-line no-console
+ if (trace) console.warn(trace);
+ }
recent_async_deriveds.delete(signal);
@@ -595,7 +597,7 @@ export function get(signal) {
if (signal.trace) {
signal.trace();
} else {
- var trace = get_error('traced at');
+ trace = get_error('traced at');
if (trace) {
var entry = tracing_expressions.entries.get(signal);
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
index ce7cd6bd49..a5dd7fa28a 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
@@ -1,10 +1,8 @@
import { tick } from 'svelte';
import { test } from '../../test';
+import { normalise_trace_logs } from '../../../helpers.js';
export default test({
- // TODO reinstate
- skip: true,
-
compileOptions: {
dev: true
},
@@ -15,13 +13,10 @@ export default test({
await tick();
assert.htmlEqual(target.innerHTML, 'a b 3 ');
- assert.equal(
- warnings[0],
- 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
- );
-
- assert.equal(warnings[1].name, 'traced at');
-
- assert.equal(warnings.length, 2);
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `values.length`. 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/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
index ad333a573a..747648e83f 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
@@ -2,9 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- // TODO reinstate this
- skip: true,
-
compileOptions: {
dev: true
},
From c89f6abae863c6db00557e0a283aa0e6f31b45c6 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 17 Mar 2026 16:11:32 -0400
Subject: [PATCH 08/92] feat: allow `css`, `runes`, `customElement` compiler
options to be functions (#17951)
Alternative to #17950. Closes #17952
The goal of this is to allow svelte.config.js to contain functions for
setting certain options, so that there's a single source of truth for
everything that needs to interact with Svelte config (plugins, editor
extensions, etc):
```js
// svelte.config.js
export default {
compilerOptions: {
css: ({ filename }) => filename.endsWith('/OG.svelte') ? 'injected' : 'external',
experimental: {
async: true
},
runes: ({ filename }) => !filename.split(/\/\\/).includes('node_modules')
}
};
```
Once this ships, we can deprecate `dynamicCompileOptions` in
`vite-plugin-svelte`.
### 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.
- [ ] 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/evil-nails-live.md | 5 ++
packages/svelte/src/compiler/index.js | 5 +-
packages/svelte/src/compiler/migrate/index.js | 2 +
.../src/compiler/phases/2-analyze/index.js | 21 ++++--
.../3-transform/client/transform-client.js | 2 +-
.../compiler/phases/3-transform/css/index.js | 2 +-
.../3-transform/server/transform-server.js | 2 +-
packages/svelte/src/compiler/types/index.d.ts | 16 +++-
.../svelte/src/compiler/validate-options.js | 73 ++++++++++++++-----
packages/svelte/types/index.d.ts | 20 +++--
10 files changed, 108 insertions(+), 40 deletions(-)
create mode 100644 .changeset/evil-nails-live.md
diff --git a/.changeset/evil-nails-live.md b/.changeset/evil-nails-live.md
new file mode 100644
index 0000000000..16dc3f633e
--- /dev/null
+++ b/.changeset/evil-nails-live.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: allow `css`, `runes`, `customElement` compiler options to be functions
diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js
index e864c4a1f4..1d822514b9 100644
--- a/packages/svelte/src/compiler/index.js
+++ b/packages/svelte/src/compiler/index.js
@@ -23,6 +23,7 @@ export { print } from './print/index.js';
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
+
const validated = validate_component_options(options, '');
let parsed = _parse(source);
@@ -33,7 +34,9 @@ export function compile(source, options) {
const combined_options = {
...validated,
...parsed_options,
- customElementOptions
+ customElementOptions,
+ css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
+ runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
};
if (parsed.metadata.ts) {
diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js
index ce5387f4dd..0370155c12 100644
--- a/packages/svelte/src/compiler/migrate/index.js
+++ b/packages/svelte/src/compiler/migrate/index.js
@@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) {
...parsed_options,
customElementOptions,
filename: filename ?? UNKNOWN_FILENAME,
+ css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external',
+ runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined,
experimental: {
async: true
}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index fbd2e1cb8a..cadd159b3e 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -345,6 +345,8 @@ export function analyze_component(root, source, options) {
let synthetic_stores_legacy_check = [];
+ const runes_option = options.runes?.({ filename: options.filename });
+
// create synthetic bindings for store subscriptions
for (const [name, references] of module.scope.references) {
if (name[0] !== '$' || RESERVED.includes(name)) continue;
@@ -359,7 +361,7 @@ export function analyze_component(root, source, options) {
// If we're not in legacy mode through the compiler option, assume the user
// is referencing a rune and not a global store.
if (
- options.runes === false ||
+ runes_option === false ||
!is_rune(name) ||
(declaration !== null &&
// const state = $state(0) is valid
@@ -395,7 +397,7 @@ export function analyze_component(root, source, options) {
e.store_invalid_scoped_subscription(is_nested_store_subscription_node);
}
- if (options.runes !== false) {
+ if (runes_option !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) {
e.global_reference_invalid(references[0].node, name);
} else if (declaration !== null && is_rune(name)) {
@@ -447,7 +449,7 @@ export function analyze_component(root, source, options) {
const component_name = get_component_name(options.filename);
const runes =
- options.runes ??
+ runes_option ??
(has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune));
if (!runes) {
@@ -463,7 +465,10 @@ export function analyze_component(root, source, options) {
}
}
- const is_custom_element = !!options.customElementOptions || options.customElement;
+ const custom_element_from_option = options.customElement({ filename: options.filename });
+ const css = options.css({ filename: options.filename });
+ const custom_element = options.customElementOptions ?? custom_element_from_option;
+ const is_custom_element = !!options.customElementOptions || custom_element_from_option;
const name = module.scope.generate(options.name ?? component_name);
@@ -491,7 +496,7 @@ export function analyze_component(root, source, options) {
maybe_runes:
!runes &&
// if they explicitly disabled runes, use the legacy behavior
- options.runes !== false &&
+ runes_option !== false &&
![...module.scope.references.keys()].some((name) =>
['$$props', '$$restProps'].includes(name)
) &&
@@ -523,8 +528,8 @@ export function analyze_component(root, source, options) {
needs_props: false,
event_directive_node: null,
uses_event_attributes: false,
- custom_element: is_custom_element,
- inject_styles: options.css === 'injected' || is_custom_element,
+ custom_element,
+ inject_styles: css === 'injected' || is_custom_element,
accessors:
is_custom_element ||
(runes ? false : !!options.accessors) ||
@@ -680,7 +685,7 @@ export function analyze_component(root, source, options) {
w.options_deprecated_accessors(attribute);
}
- if (attribute.name === 'customElement' && !options.customElement) {
+ if (attribute.name === 'customElement' && !custom_element_from_option) {
w.options_missing_custom_element(attribute);
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
index b50a73b8b6..9328d20be3 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
@@ -595,7 +595,7 @@ export function client_component(analysis, options) {
);
}
- const ce = options.customElementOptions ?? options.customElement;
+ const ce = analysis.custom_element;
if (ce) {
const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};
diff --git a/packages/svelte/src/compiler/phases/3-transform/css/index.js b/packages/svelte/src/compiler/phases/3-transform/css/index.js
index cee7ab2791..92b2706f22 100644
--- a/packages/svelte/src/compiler/phases/3-transform/css/index.js
+++ b/packages/svelte/src/compiler/phases/3-transform/css/index.js
@@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) {
merge_with_preprocessor_map(css, options, css.map.sources[0]);
- if (dev && options.css === 'injected' && css.code) {
+ if (dev && analysis.inject_styles && css.code) {
css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`;
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
index b9f1441bff..90a693b99a 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
@@ -300,7 +300,7 @@ export function server_component(analysis, options) {
const body = [...state.hoisted, ...module.body];
- if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) {
+ if (analysis.css.ast !== null && analysis.inject_styles && !analysis.custom_element) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts
index fce3f62c5c..c04466a24d 100644
--- a/packages/svelte/src/compiler/types/index.d.ts
+++ b/packages/svelte/src/compiler/types/index.d.ts
@@ -73,9 +73,11 @@ export interface CompileOptions extends ModuleCompileOptions {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -101,8 +103,10 @@ export interface CompileOptions extends ModuleCompileOptions {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -142,7 +146,7 @@ export interface CompileOptions extends ModuleCompileOptions {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@@ -248,18 +252,22 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions &
Required,
| keyof ModuleCompileOptions
| 'name'
+ | 'customElement'
| 'compatibility'
| 'outputFilename'
| 'cssOutputFilename'
| 'sourcemap'
+ | 'css'
| 'runes'
> & {
name: CompileOptions['name'];
+ customElement: (options: { filename: string }) => boolean;
outputFilename: CompileOptions['outputFilename'];
cssOutputFilename: CompileOptions['cssOutputFilename'];
sourcemap: CompileOptions['sourcemap'];
compatibility: Required['compatibility']>;
- runes: CompileOptions['runes'];
+ css: (options: { filename: string }) => 'injected' | 'external';
+ runes: (options: { filename: string }) => boolean | undefined;
customElementOptions: AST.SvelteOptions['customElement'];
hmr: CompileOptions['hmr'];
};
diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js
index a94a553311..f7c56aa33c 100644
--- a/packages/svelte/src/compiler/validate-options.js
+++ b/packages/svelte/src/compiler/validate-options.js
@@ -51,24 +51,28 @@ const common_options = {
const component_options = {
accessors: deprecate(w.options_deprecated_accessors, boolean(false)),
- css: validator('external', (input) => {
- if (input === true || input === false) {
- throw_error(
- 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
- );
- }
- if (input === 'none') {
- throw_error(
- 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
- );
- }
+ /** @type {Validator<'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'), (options: { filename: string }) => 'injected' | 'external'>} */
+ css: parametric(
+ /** @type {(options: { filename: string }) => 'injected' | 'external'} */ (() => 'external'),
+ (input) => {
+ if (input === true || input === false) {
+ throw_error(
+ 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
+ );
+ }
+ if (input === 'none') {
+ throw_error(
+ 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
+ );
+ }
- if (input !== 'external' && input !== 'injected') {
- throw_error(`css should be either "external" (default, recommended) or "injected"`);
- }
+ if (input !== 'external' && input !== 'injected') {
+ throw_error(`css should be either "external" (default, recommended) or "injected"`);
+ }
- return input;
- }),
+ return /** @type {'external' | 'injected'} */ (input);
+ }
+ ),
cssHash: fun(({ css, filename, hash }) => {
return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`;
@@ -77,7 +81,17 @@ const component_options = {
// TODO this is a sourcemap option, would be good to put under a sourcemap namespace
cssOutputFilename: string(undefined),
- customElement: boolean(false),
+ /** @type {Validator boolean), (options: { filename: string }) => boolean>} */
+ customElement: parametric(
+ /** @type {(options: { filename: string }) => boolean} */ (() => false),
+ (input, keypath) => {
+ if (typeof input !== 'boolean') {
+ throw_error(`${keypath} should be true or false`);
+ }
+
+ return /** @type {boolean} */ (input);
+ }
+ ),
discloseVersion: boolean(true),
@@ -107,7 +121,8 @@ const component_options = {
preserveWhitespace: boolean(false),
- runes: boolean(undefined),
+ /** @type {Validator boolean | undefined), () => boolean | undefined>} */
+ runes: parametric(() => /** @type {boolean | undefined} */ (undefined)),
hmr: boolean(false),
@@ -318,6 +333,28 @@ function fun(fallback) {
});
}
+/**
+ * @template {(...args: any[]) => any} F
+ * @param {F} fallback
+ * @param {(value: unknown, keypath: string) => ReturnType} [normalize]
+ * @returns {Validator}
+ */
+function parametric(fallback, normalize = (value) => /** @type {ReturnType} */ (value)) {
+ return validator(fallback, (input, keypath) => {
+ if (typeof input === 'function') {
+ /** @type {(...args: Parameters) => ReturnType} */
+ const normalized = (...args) => normalize(input(...args), keypath);
+
+ return /** @type {F} */ (/** @type {unknown} */ (normalized));
+ }
+
+ /** @type {(...args: Parameters) => ReturnType} */
+ const normalized = (..._args) => normalize(input, keypath);
+
+ return /** @type {F} */ (/** @type {unknown} */ (normalized));
+ });
+}
+
/** @param {string} msg */
function throw_error(msg) {
e.options_invalid_value(null, msg);
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 31ef9110df..371a823225 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -1030,9 +1030,11 @@ declare module 'svelte/compiler' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -1058,8 +1060,10 @@ declare module 'svelte/compiler' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -1099,7 +1103,7 @@ declare module 'svelte/compiler' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@@ -3006,9 +3010,11 @@ declare module 'svelte/types/compiler/interfaces' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -3034,8 +3040,10 @@ declare module 'svelte/types/compiler/interfaces' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -3075,7 +3083,7 @@ declare module 'svelte/types/compiler/interfaces' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
From 7ec156a7b025a916da2745808c72c92dc6e05593 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 17:39:27 -0400
Subject: [PATCH 09/92] Version Packages (#17953)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.54.0
### Minor Changes
- feat: allow `css`, `runes`, `customElement` compiler options to be
functions ([#17951](https://github.com/sveltejs/svelte/pull/17951))
### Patch Changes
- fix: reinstate reactivity loss tracking
([#17801](https://github.com/sveltejs/svelte/pull/17801))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/evil-nails-live.md | 5 -----
.changeset/twelve-stars-serve.md | 5 -----
packages/svelte/CHANGELOG.md | 10 ++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
5 files changed, 12 insertions(+), 12 deletions(-)
delete mode 100644 .changeset/evil-nails-live.md
delete mode 100644 .changeset/twelve-stars-serve.md
diff --git a/.changeset/evil-nails-live.md b/.changeset/evil-nails-live.md
deleted file mode 100644
index 16dc3f633e..0000000000
--- a/.changeset/evil-nails-live.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': minor
----
-
-feat: allow `css`, `runes`, `customElement` compiler options to be functions
diff --git a/.changeset/twelve-stars-serve.md b/.changeset/twelve-stars-serve.md
deleted file mode 100644
index 6a4d7ecfa7..0000000000
--- a/.changeset/twelve-stars-serve.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: reinstate reactivity loss tracking
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index a22b7b2174..7c1311ae84 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,15 @@
# svelte
+## 5.54.0
+
+### Minor Changes
+
+- feat: allow `css`, `runes`, `customElement` compiler options to be functions ([#17951](https://github.com/sveltejs/svelte/pull/17951))
+
+### Patch Changes
+
+- fix: reinstate reactivity loss tracking ([#17801](https://github.com/sveltejs/svelte/pull/17801))
+
## 5.53.13
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index f8bd216073..270d02d621 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.53.13",
+ "version": "5.54.0",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index a10453ad9f..191d53016a 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.53.13';
+export const VERSION = '5.54.0';
export const PUBLIC_VERSION = '5';
From 0adc22c9ae5f98718bf5b7b83753767cc919b0e1 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 18 Mar 2026 12:43:17 -0400
Subject: [PATCH 10/92] fix: defer batch resolution until earlier intersecting
batches have committed (#17162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This fixes an awkward bug with `each` blocks containing `await`,
especially keyed `each` blocks.
If you do `array.push(...)` multiple times in distinct batches,
something weird happens — to the `each` block, the array looks this...
```js
[1]
```
...then this...
```js
[undefined, 2]
```
...then this...
```js
[undefined, undefined, 3]
```
...and so on. That's because as far as Svelte's reactivity is concerned,
what we're _really_ doing is assigning to `array[0]` then `array[1]`
then `array[2]`. Those (along with `array.length`) are each backed by
independent sources, which we can rewind individually when we need to
'apply' a batch. When it comes to sources that actually _are_
independent, this is useful, since we apply the changes from batch B to
the DOM while we're still waiting for a promise in batch A to resolve.
But in this case it's not ideal, because you would expect these changes
to accumulate.
In particular, this fails with keyed `each` blocks because duplicate
keys are disallowed (assuming the key function doesn't break on
`undefined` _before_ the duplicate check happens).
This PR fixes it by stacking batches that are interconnected.
Specifically, if a later batch has some (but not all) sources in common
with an earlier batch, then when we apply the batch we include the
sources from the earlier batch, and block it until the earlier batch
commits. When the earlier batch commits, it will check to see if doing
so unblocks any later batches, and if so process them.
In the course of working on this I realised that `SvelteSet` and
`SvelteMap` aren't async-ready — will follow up this PR with ones for
those.
Fixes #17050
---------
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen
---
.changeset/spicy-teeth-tan.md | 5 +
.../src/internal/client/reactivity/async.js | 7 +-
.../src/internal/client/reactivity/batch.js | 148 ++++++++++---
.../internal/client/reactivity/deriveds.js | 2 +-
.../samples/async-derived-indirect/_config.js | 29 +++
.../async-derived-indirect/main.svelte | 21 ++
.../samples/async-each-overlap/_config.js | 102 ---------
.../async-ignore-skipped-block/_config.js | 29 +++
.../async-ignore-skipped-block/main.svelte | 14 ++
.../async-later-sync-overlaps/_config.js | 30 +++
.../async-later-sync-overlaps/main.svelte | 17 ++
.../async-overlapping-array/_config.js | 205 ++++++++++++++++++
.../main.svelte | 8 +-
13 files changed, 474 insertions(+), 143 deletions(-)
create mode 100644 .changeset/spicy-teeth-tan.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
delete mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js
rename packages/svelte/tests/runtime-runes/samples/{async-each-overlap => async-overlapping-array}/main.svelte (84%)
diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md
new file mode 100644
index 0000000000..c7efa65130
--- /dev/null
+++ b/.changeset/spicy-teeth-tan.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: defer batch resolution until earlier intersecting batches have committed
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 9b24400840..d713385c75 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -307,15 +307,16 @@ export function wait(blockers) {
* @returns {(skip?: boolean) => void}
*/
export function increment_pending() {
- var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
+ var effect = /** @type {Effect} */ (active_effect);
+ var boundary = /** @type {Boundary} */ (effect.b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1, batch);
- batch.increment(blocking);
+ batch.increment(blocking, effect);
return (skip = false) => {
boundary.update_pending_count(-1, batch);
- batch.decrement(blocking, skip);
+ batch.decrement(blocking, effect, skip);
};
}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index b100d559d2..3b10d6ebe6 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -90,20 +90,20 @@ var source_stacks = DEV ? new Set() : null;
let uid = 1;
export class Batch {
- // for debugging. TODO remove once async is stable
id = uid++;
/**
- * The current values of any sources that are updated in this batch
+ * 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)
* They keys of this map are identical to `this.#previous`
- * @type {Map}
+ * @type {Map}
*/
current = new Map();
/**
- * The values of any sources that are updated in this batch _before_ those updates took place.
+ * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place.
* They keys of this map are identical to `this.#current`
- * @type {Map}
+ * @type {Map}
*/
previous = new Map();
@@ -121,14 +121,16 @@ export class Batch {
#discard_callbacks = new Set();
/**
- * The number of async effects that are currently in flight
+ * Async effects that are currently in flight
+ * @type {Map}
*/
- #pending = 0;
+ #pending = new Map();
/**
- * The number of async effects that are currently in flight, _not_ inside a pending boundary
+ * Async effects that are currently in flight, _not_ inside a pending boundary
+ * @type {Map}
*/
- #blocking_pending = 0;
+ #blocking_pending = new Map();
/**
* A deferred that resolves when the batch is committed, used with `settled()`
@@ -168,8 +170,35 @@ export class Batch {
#decrement_queued = false;
+ /** @type {Set} */
+ #blockers = new Set();
+
#is_deferred() {
- return this.is_fork || this.#blocking_pending > 0;
+ 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()) {
+ var skipped = false;
+ var e = effect;
+
+ while (e.parent !== null) {
+ if (this.#skipped_branches.has(e)) {
+ skipped = true;
+ break;
+ }
+
+ e = e.parent;
+ }
+
+ if (!skipped) {
+ return true;
+ }
+ }
+ }
+
+ return false;
}
/**
@@ -264,7 +293,7 @@ export class Batch {
collected_effects = null;
legacy_updates = null;
- if (this.#is_deferred()) {
+ if (this.#is_deferred() || this.#is_blocked()) {
this.#defer_effects(render_effects);
this.#defer_effects(effects);
@@ -272,7 +301,7 @@ export class Batch {
reset_branch(e, t);
}
} else {
- if (this.#pending === 0) {
+ if (this.#pending.size === 0) {
batches.delete(this);
}
@@ -383,17 +412,18 @@ export class Batch {
/**
* Associate a change to a given source with the current
* batch, noting its previous and current values
- * @param {Source} source
+ * @param {Value} source
* @param {any} old_value
+ * @param {boolean} [is_derived]
*/
- capture(source, old_value) {
+ capture(source, old_value, is_derived = false) {
if (old_value !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, old_value);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
- this.current.set(source, source.v);
+ this.current.set(source, [source.v, is_derived]);
batch_values?.set(source, source.v);
}
}
@@ -453,11 +483,13 @@ export class Batch {
/** @type {Source[]} */
var sources = [];
- for (const [source, value] of this.current) {
+ for (const [source, [value, is_derived]] of this.current) {
if (batch.current.has(source)) {
- if (is_earlier && value !== batch.current.get(source)) {
+ var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring
+
+ if (is_earlier && value !== batch_value) {
// bring the value up to date
- batch.current.set(source, value);
+ batch.current.set(source, [value, is_derived]);
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
@@ -507,24 +539,56 @@ 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();
+ }
+ }
+ }
}
/**
- *
* @param {boolean} blocking
+ * @param {Effect} effect
*/
- increment(blocking) {
- this.#pending += 1;
- if (blocking) this.#blocking_pending += 1;
+ increment(blocking, effect) {
+ let pending_count = this.#pending.get(effect) ?? 0;
+ this.#pending.set(effect, pending_count + 1);
+
+ if (blocking) {
+ let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
+ this.#blocking_pending.set(effect, blocking_pending_count + 1);
+ }
}
/**
* @param {boolean} blocking
+ * @param {Effect} effect
* @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
- decrement(blocking, skip) {
- this.#pending -= 1;
- if (blocking) this.#blocking_pending -= 1;
+ 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);
+ }
+
+ if (blocking) {
+ let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
+
+ if (blocking_pending_count === 1) {
+ this.#blocking_pending.delete(effect);
+ } else {
+ this.#blocking_pending.set(effect, blocking_pending_count - 1);
+ }
+ }
if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
@@ -597,15 +661,37 @@ export class Batch {
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
- batch_values = new Map(this.current);
+ batch_values = new Map();
+ for (const [source, [value]] of this.current) {
+ batch_values.set(source, value);
+ }
- // ...and undo changes belonging to other batches
+ // ...and undo changes belonging to other batches unless they block this one
for (const batch of batches) {
if (batch === this || batch.is_fork) continue;
- for (const [source, previous] of batch.previous) {
- if (!batch_values.has(source)) {
- batch_values.set(source, previous);
+ // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset
+ 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
+ // 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 (intersects && differs) {
+ this.#blockers.add(batch);
+ } else {
+ for (const [source, previous] of batch.previous) {
+ if (!batch_values.has(source)) {
+ batch_values.set(source, previous);
+ }
}
}
}
@@ -1065,7 +1151,7 @@ export function fork(fn) {
batch.is_fork = false;
// apply changes and update write versions so deriveds see the change
- for (var [source, value] of batch.current) {
+ for (var [source, [value]] of batch.current) {
source.v = value;
source.wv = increment_write_version();
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 7b932a8356..5da0df0670 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -396,7 +396,7 @@ export function update_derived(derived) {
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
derived.v = value;
- current_batch?.capture(derived, old_value);
+ current_batch?.capture(derived, old_value, true);
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
new file mode 100644
index 0000000000..9d97b736be
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
@@ -0,0 +1,29 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a, b, resolve] = target.querySelectorAll('button');
+
+ a.click();
+ await tick();
+ b.click();
+ await tick();
+ resolve.click();
+ await tick();
+ resolve.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a
+ b
+ resolve
+ hi
+ 1
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
new file mode 100644
index 0000000000..f0d3bdc809
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
@@ -0,0 +1,21 @@
+
+
+ (a++)}>a
+ (b++)}>b
+ (queued.shift()?.())}>resolve
+
+{#if a_b}hi{/if}
+{await push(a_b)}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
deleted file mode 100644
index d03f9ad09d..0000000000
--- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { tick } from 'svelte';
-import { test } from '../../test';
-
-export default test({
- skip: true,
- async test({ assert, target }) {
- const [add, shift] = target.querySelectorAll('button');
-
- add.click();
- await tick();
- add.click();
- await tick();
- add.click();
- await tick();
-
- // TODO pending count / number of pushes is off
-
- assert.htmlEqual(
- target.innerHTML,
- `
- add
- shift
- pending=6 values.length=1 values=[1]
-
-
- `
- );
-
- shift.click();
- await tick();
- shift.click();
- await tick();
- assert.htmlEqual(
- target.innerHTML,
- `
- add
- shift
- pending=4 values.length=2 values=[1,2]
-
-
- `
- );
-
- shift.click();
- await tick();
- shift.click();
- await tick();
- assert.htmlEqual(
- target.innerHTML,
- `
- add
- shift
- pending=2 values.length=3 values=[1,2,3]
-
-
- `
- );
-
- shift.click();
- await tick();
- shift.click();
- await tick();
- assert.htmlEqual(
- target.innerHTML,
- `
- add
- shift
- pending=0 values.length=4 values=[1,2,3,4]
- not keyed:
-
1
-
2
-
3
-
4
-
-
- `
- );
- }
-});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js
new file mode 100644
index 0000000000..1d1a489d67
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js
@@ -0,0 +1,29 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+
+ const [a, b] = target.querySelectorAll('button');
+
+ assert.htmlEqual(target.innerHTML, `a 0 b 0 hello
`);
+
+ a.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `a 0 b 0 hello
`);
+
+ a.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `a 2 b 0 hello
`);
+
+ a.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `a 2 b 0 hello
`);
+
+ // if we don't skip over the never-resolving promise in the `else` block, we will never update
+ b.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `a 3 b 1 hello
`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte
new file mode 100644
index 0000000000..4ef0694d40
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte
@@ -0,0 +1,14 @@
+
+
+ (a++, show = !show)}>a {a}
+ (b++, show = !show)}>b {b}
+
+{#if show}
+ hello
+{:else}
+ {await new Promise(() => {})}
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js
new file mode 100644
index 0000000000..a65c8a6fba
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js
@@ -0,0 +1,30 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a_b, b, resolve] = target.querySelectorAll('button');
+
+ a_b.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ 'a_b 0_0 b 0 resolve 0'
+ );
+
+ b.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ 'a_b 0_0 b 0 resolve 0'
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ 'a_b 1_2 b 2 resolve 1'
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte
new file mode 100644
index 0000000000..8a9e2a866c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte
@@ -0,0 +1,17 @@
+
+
+ {a++;b++}}>a_b {a}_{b}
+ (b++)}>b {b}
+ (deferreds.shift()?.())}>resolve
+{await push(a)}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js
new file mode 100644
index 0000000000..2dad5babea
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js
@@ -0,0 +1,205 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+
+ const [add, shift, pop] = target.querySelectorAll('button');
+
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=6 values.length=1 values=[1]
+
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=4 values.length=2 values=[1,2]
+
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=2 values.length=3 values=[1,2,3]
+
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=0 values.length=4 values=[1,2,3,4]
+ not keyed:
+
1
+
2
+
3
+
4
+
+
+ `
+ );
+
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=8 values.length=4 values=[1,2,3,4]
+ not keyed:
+
1
+
2
+
3
+
4
+
+
+ `
+ );
+
+ // pop should have no effect until earlier promises have also resolved
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=6 values.length=4 values=[1,2,3,4]
+ not keyed:
+
1
+
2
+
3
+
4
+
+
+ `
+ );
+
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ pop
+ pending=0 values.length=8 values=[1,2,3,4,5,6,7,8]
+ not keyed:
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+ keyed:
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte
similarity index 84%
rename from packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte
rename to packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte
index af9d395457..e41b6926b8 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte
@@ -11,18 +11,14 @@
return p.promise;
}
- function shift() {
- const fn = queue.shift();
- if (fn) fn();
- }
-
function addValue() {
values.push(values.length+1);
}
add
-shift
+ queue.shift()?.()}>shift
+ queue.pop()?.()}>pop
pending={$effect.pending()}
From 803c565febd07be8fd08377ffc51f23d7bf6b8f3 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Fri, 20 Mar 2026 08:37:21 +0100
Subject: [PATCH 11/92] fix: properly invoke `iterator.return()` during
reactivity loss check (#17966)
The logic was flipped - `iterator.return()` should be called when the
iterator is abnormally ending, not when it's normally ending.
Fixes #16610
---
.changeset/tasty-carrots-tie.md | 5 +++
.../src/internal/client/reactivity/async.js | 4 +-
.../_config.js | 21 ++++++++++
.../main.svelte | 41 +++++++++++++++++++
4 files changed, 69 insertions(+), 2 deletions(-)
create mode 100644 .changeset/tasty-carrots-tie.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte
diff --git a/.changeset/tasty-carrots-tie.md b/.changeset/tasty-carrots-tie.md
new file mode 100644
index 0000000000..5d6cb8025d
--- /dev/null
+++ b/.changeset/tasty-carrots-tie.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: properly invoke `iterator.return()` during reactivity loss check
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index d713385c75..7b0b108e4c 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -209,8 +209,8 @@ export async function* for_await_track_reactivity_loss(iterable) {
yield value;
}
} finally {
- // If the iterator had a normal 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 and `return` is defined on the iterator, call it and return the value
+ if (!normal_completion && 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-break-return/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js
new file mode 100644
index 0000000000..3429380800
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_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 -> number -> return -> ended ');
+
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `values.length`. 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-break-return/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte
new file mode 100644
index 0000000000..05ad7a6ee5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {await get_result()}
+
+ {#snippet pending()}
+ pending
+ {/snippet}
+
From 425fba33fed0572e952d364d1c59f7af01aedd07 Mon Sep 17 00:00:00 2001
From: Paolo Ricciuti
Date: Fri, 20 Mar 2026 22:23:57 +0100
Subject: [PATCH 12/92] fix: hydration comments during hmr (#17975)
Closes #17972
Claude found this fix I had a look and I think it makes sense (we are
injecting a new comment which messes up the marching during hydration).
I'm slightly confused why this only applied to `css_props` but I guess
that's because there's an "hidden" element there which makes it special.
I don't super-like the `if` situation, but I guess it is what it is.
Also the test was using `hmr` without `dev` and this brought to my
attention that we were just assuming components return something while
it's not the case...I guess it doesn't really matter unless you are
using `hmr` in prod which is not a thing but fixing this is simple so we
might just as well doing it
---
.changeset/calm-mice-allow.md | 5 ++++
.../svelte/src/internal/client/dev/hmr.js | 25 +++++++++++++------
.../samples/css-props-hmr/Component.svelte | 7 ++++++
.../samples/css-props-hmr/_config.js | 7 ++++++
.../samples/css-props-hmr/main.svelte | 5 ++++
5 files changed, 41 insertions(+), 8 deletions(-)
create mode 100644 .changeset/calm-mice-allow.md
create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte
create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/_config.js
create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte
diff --git a/.changeset/calm-mice-allow.md b/.changeset/calm-mice-allow.md
new file mode 100644
index 0000000000..eff71e1082
--- /dev/null
+++ b/.changeset/calm-mice-allow.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: hydration comments during hmr
diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js
index 071901aafc..4687708c90 100644
--- a/packages/svelte/src/internal/client/dev/hmr.js
+++ b/packages/svelte/src/internal/client/dev/hmr.js
@@ -34,7 +34,13 @@ export function hmr(fn) {
let start = create_comment();
let end = create_comment();
- anchor.before(start);
+ // During hydration, inserting the start comment before the anchor could
+ // corrupt the DOM tree that the hydration walker is navigating (e.g. when
+ // a component is inside a CSS props wrapper gh-issue#17972). We defer the insertion until
+ // after the component has hydrated.
+ if (!hydrating) {
+ anchor.before(start);
+ }
block(() => {
if (component === (component = get(current))) {
@@ -52,13 +58,13 @@ export function hmr(fn) {
if (ran) set_should_intro(false);
// preserve getters/setters
- Object.defineProperties(
- instance,
- Object.getOwnPropertyDescriptors(
- // @ts-expect-error
- new.target ? new component(anchor, props) : component(anchor, props)
- )
- );
+ var result =
+ // @ts-expect-error
+ new.target ? new component(anchor, props) : component(anchor, props);
+ // a component is not guaranteed to return something and we can't invoke getOwnPropertyDescriptors on undefined
+ if (result) {
+ Object.defineProperties(instance, Object.getOwnPropertyDescriptors(result));
+ }
if (ran) set_should_intro(true);
});
@@ -67,6 +73,9 @@ export function hmr(fn) {
ran = true;
if (hydrating) {
+ // Insert start comment now that hydration is done, so it doesn't
+ // corrupt the hydration walk
+ anchor.before(start);
anchor = hydrate_node;
}
diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte b/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte
new file mode 100644
index 0000000000..ac73501cca
--- /dev/null
+++ b/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte
@@ -0,0 +1,7 @@
+Hello
+
+
\ No newline at end of file
diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js b/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js
new file mode 100644
index 0000000000..2cd696d584
--- /dev/null
+++ b/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js
@@ -0,0 +1,7 @@
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ hmr: true
+ }
+});
diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte b/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte
new file mode 100644
index 0000000000..eed9b57caf
--- /dev/null
+++ b/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte
@@ -0,0 +1,5 @@
+
+
+
\ No newline at end of file
From 6b33dd2a1e8aa48dc88c9ce6e19c4a49a2eac51a Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Sat, 21 Mar 2026 13:29:39 +0100
Subject: [PATCH 13/92] fix: group sync statements (#17977)
We were just putting each statement into its own promise. Besides this
being bad for perf, it also introduces subtle timing issues - the
execution order of the code could change in bad ways. Fixes #17940
---
.changeset/puny-masks-run.md | 5 +
.../src/compiler/phases/2-analyze/index.js | 36 ++++-
.../3-transform/shared/transform-async.js | 124 ++++++++++--------
.../svelte/src/compiler/phases/types.d.ts | 2 +-
.../_expected/client/index.svelte.js | 16 ++-
.../_expected/server/index.svelte.js | 16 ++-
.../async-top-level-group-sync-run/_config.js | 3 +
.../_expected/client/index.svelte.js | 26 ++++
.../_expected/server/index.svelte.js | 21 +++
.../index.svelte | 9 ++
10 files changed, 184 insertions(+), 74 deletions(-)
create mode 100644 .changeset/puny-masks-run.md
create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js
create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js
create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js
create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte
diff --git a/.changeset/puny-masks-run.md b/.changeset/puny-masks-run.md
new file mode 100644
index 0000000000..162abe4ae7
--- /dev/null
+++ b/.changeset/puny-masks-run.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: group sync statements
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index cadd159b3e..dec0081aa9 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -1074,6 +1074,9 @@ function calculate_blockers(instance, analysis) {
let awaited = false;
+ /** @type {Array} */
+ let sync_group = [];
+
// TODO this should probably be attached to the scope?
const promises = b.id('$$promises');
@@ -1088,6 +1091,13 @@ function calculate_blockers(instance, analysis) {
binding.blocker = blocker;
}
+ function flush_sync_group() {
+ if (sync_group.length === 0) return;
+
+ analysis.instance_body.async.push({ nodes: sync_group, has_await: false });
+ sync_group = [];
+ }
+
/**
* Analysis of blockers for functions is deferred until we know which statements are async/blockers
* @type {Array}
@@ -1149,6 +1159,9 @@ function calculate_blockers(instance, analysis) {
trace_references(declarator, reads, writes, instance.scope);
+ // Needs to happen before blocker computation
+ if (has_await) flush_sync_group();
+
const blocker = /** @type {NonNullable} */ (
b.member(promises, b.literal(analysis.instance_body.async.length), true)
);
@@ -1161,11 +1174,12 @@ function calculate_blockers(instance, analysis) {
push_declaration(id, blocker);
}
- // one declarator per declaration, makes things simpler
- analysis.instance_body.async.push({
- node: declarator,
- has_await
- });
+ if (has_await) {
+ // one declarator per declaration, makes things simpler
+ analysis.instance_body.async.push({ nodes: [declarator], has_await: true });
+ } else {
+ sync_group.push(declarator);
+ }
}
}
} else if (awaited) {
@@ -1177,6 +1191,9 @@ function calculate_blockers(instance, analysis) {
trace_references(node, reads, writes, instance.scope);
+ // Needs to happen before blocker computation
+ if (has_await) flush_sync_group();
+
const blocker = /** @type {NonNullable} */ (
b.member(promises, b.literal(analysis.instance_body.async.length), true)
);
@@ -1187,15 +1204,20 @@ function calculate_blockers(instance, analysis) {
if (node.type === 'ClassDeclaration') {
push_declaration(node.id, blocker);
- analysis.instance_body.async.push({ node, has_await });
+ }
+
+ if (has_await) {
+ analysis.instance_body.async.push({ nodes: [node], has_await: true });
} else {
- analysis.instance_body.async.push({ node, has_await });
+ sync_group.push(node);
}
} else {
analysis.instance_body.sync.push(node);
}
}
+ flush_sync_group();
+
for (const fn of functions) {
/** @type {Set} */
const reads_writes = new Set();
diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
index 5c8f901d7c..ac90f1db4b 100644
--- a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
+++ b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js
@@ -47,64 +47,24 @@ export function transform_body(instance_body, runner, transform) {
// Thunks for the await expressions
if (instance_body.async.length > 0) {
- const thunks = instance_body.async.map((s) => {
- if (s.node.type === 'VariableDeclarator') {
- const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ (
- transform(b.var(s.node.id, s.node.init))
- );
-
- const statements =
- visited.type === 'VariableDeclaration'
- ? visited.declarations.map((node) => {
- if (
- node.id.type === 'Identifier' &&
- (node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array'))
- ) {
- // this is an intermediate declaration created in VariableDeclaration.js;
- // subsequent statements depend on it
- return b.var(node.id, node.init);
- }
-
- return b.stmt(b.assignment('=', node.id, node.init ?? b.void0));
- })
- : [];
-
- if (statements.length === 1) {
- const statement = /** @type {ESTree.ExpressionStatement} */ (statements[0]);
- return b.thunk(statement.expression, s.has_await);
- }
-
- return b.thunk(b.block(statements), s.has_await);
- }
+ const thunks = instance_body.async.map((entry) => {
+ /** @type {ESTree.Statement[]} */
+ const entry_statements = [];
- if (s.node.type === 'ClassDeclaration') {
- return b.thunk(
- b.assignment(
- '=',
- s.node.id,
- /** @type {ESTree.ClassExpression} */ ({ ...s.node, type: 'ClassExpression' })
- ),
- s.has_await
- );
+ for (const node of entry.nodes) {
+ entry_statements.push(...transform_async_node(node, transform));
}
- if (s.node.type === 'ExpressionStatement') {
- // the expression may be a $inspect call, which will be transformed into an empty statement
- const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ (
- transform(s.node.expression)
- );
-
- if (expression.type === 'EmptyStatement') {
- // Keep indices stable for async sequencing while avoiding array holes in run([...]).
- return b.thunk(b.void0, false);
- }
+ if (entry_statements.length === 0) {
+ // Keep indices stable for async sequencing while avoiding array holes in run([...]).
+ return b.thunk(b.void0, false);
+ }
- return expression.type === 'AwaitExpression'
- ? b.thunk(expression, true)
- : b.thunk(b.unary('void', expression), s.has_await);
+ if (entry_statements.length === 1 && entry_statements[0].type === 'ExpressionStatement') {
+ return b.thunk(entry_statements[0].expression, entry.has_await);
}
- return b.thunk(b.block([/** @type {ESTree.Statement} */ (transform(s.node))]), s.has_await);
+ return b.thunk(b.block(entry_statements), entry.has_await);
});
// TODO get the `$$promises` ID from scope
@@ -113,3 +73,63 @@ export function transform_body(instance_body, runner, transform) {
return statements;
}
+
+/**
+ * @param {ESTree.Statement | ESTree.VariableDeclarator} node
+ * @param {(node: ESTree.Node) => ESTree.Node} transform
+ * @returns {ESTree.Statement[]}
+ */
+function transform_async_node(node, transform) {
+ if (node.type === 'VariableDeclarator') {
+ const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ (
+ transform(b.var(node.id, node.init))
+ );
+
+ return visited.type === 'VariableDeclaration'
+ ? visited.declarations.map((node) => {
+ if (
+ node.id.type === 'Identifier' &&
+ (node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array'))
+ ) {
+ // This intermediate declaration is created in VariableDeclaration.js;
+ // subsequent statements may depend on it.
+ return b.var(node.id, node.init);
+ }
+
+ return b.stmt(b.assignment('=', node.id, node.init ?? b.void0));
+ })
+ : [];
+ }
+
+ if (node.type === 'ClassDeclaration') {
+ return [
+ b.stmt(
+ b.assignment(
+ '=',
+ node.id,
+ /** @type {ESTree.ClassExpression} */ ({ ...node, type: 'ClassExpression' })
+ )
+ )
+ ];
+ }
+
+ if (node.type === 'ExpressionStatement') {
+ // The expression may be a $inspect call, which will be transformed into an empty statement.
+ const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ (
+ transform(node.expression)
+ );
+
+ if (expression.type === 'EmptyStatement') {
+ return [];
+ }
+
+ if (expression.type === 'AwaitExpression') {
+ return [b.stmt(expression)];
+ }
+
+ return [b.stmt(b.unary('void', expression))];
+ }
+
+ const statement = /** @type {ESTree.Statement | ESTree.EmptyStatement} */ (transform(node));
+ return statement.type === 'EmptyStatement' ? [] : [statement];
+}
diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts
index 5397ea45f9..a1a85ce145 100644
--- a/packages/svelte/src/compiler/phases/types.d.ts
+++ b/packages/svelte/src/compiler/phases/types.d.ts
@@ -131,7 +131,7 @@ export interface ComponentAnalysis extends Analysis {
instance_body: {
hoisted: Array;
sync: Array;
- async: Array<{ node: Statement | VariableDeclarator; has_await: boolean }>;
+ async: Array<{ nodes: Array; has_await: boolean }>;
declarations: Array;
};
}
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
index a8b1a43495..4f06d9ddbf 100644
--- 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
@@ -10,13 +10,15 @@ export default function Async_in_derived($$anchor, $$props) {
var $$promises = $.run([
async () => yes1 = await $.async_derived(() => 1),
async () => yes2 = await $.async_derived(async () => foo(await 1)),
- () => no1 = $.derived(async () => {
- return await 1;
- }),
-
- () => no2 = $.derived(() => async () => {
- return await 1;
- })
+ () => {
+ no1 = $.derived(async () => {
+ return await 1;
+ });
+
+ no2 = $.derived(() => async () => {
+ return await 1;
+ });
+ }
]);
var fragment = $.comment();
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
index 1697e3adc6..3a53475944 100644
--- 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
@@ -8,13 +8,15 @@ export default function Async_in_derived($$renderer, $$props) {
var $$promises = $$renderer.run([
async () => yes1 = await $.async_derived(() => 1),
async () => yes2 = await $.async_derived(async () => foo(await 1)),
- () => no1 = $.derived(async () => {
- return await 1;
- }),
-
- () => no2 = $.derived(() => async () => {
- return await 1;
- })
+ () => {
+ no1 = $.derived(async () => {
+ return await 1;
+ });
+
+ no2 = $.derived(() => async () => {
+ return await 1;
+ });
+ }
]);
if (true) {
diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js
new file mode 100644
index 0000000000..2e30bbeb16
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_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-top-level-group-sync-run/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js
new file mode 100644
index 0000000000..8fb09fadd2
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js
@@ -0,0 +1,26 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/async';
+import * as $ from 'svelte/internal/client';
+
+export default function Async_top_level_group_sync_run($$anchor) {
+ var a,
+ // these should be grouped into one, having an async tick inbetween
+ // would change how the code runs and could introduce subtle timing bugs
+ b,
+ c;
+
+ var $$promises = $.run([
+ async () => a = await Promise.resolve(1),
+ () => {
+ b = a + 1;
+ c = b + 1;
+ }
+ ]);
+
+ $.next();
+
+ var text = $.text();
+
+ $.template_effect(() => $.set_text(text, c), void 0, void 0, [$$promises[1]]);
+ $.append($$anchor, text);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js
new file mode 100644
index 0000000000..43db129746
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js
@@ -0,0 +1,21 @@
+import 'svelte/internal/flags/async';
+import * as $ from 'svelte/internal/server';
+
+export default function Async_top_level_group_sync_run($$renderer) {
+ var a,
+ // these should be grouped into one, having an async tick inbetween
+ // would change how the code runs and could introduce subtle timing bugs
+ b,
+ c;
+
+ var $$promises = $$renderer.run([
+ async () => a = await Promise.resolve(1),
+ () => {
+ b = a + 1;
+ c = b + 1;
+ }
+ ]);
+
+ $$renderer.push(``);
+ $$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(c)));
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte
new file mode 100644
index 0000000000..98f602312e
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte
@@ -0,0 +1,9 @@
+
+
+{c}
From 8e4de9b14500e195ec7f084c8c32b1782b31c5c9 Mon Sep 17 00:00:00 2001
From: Nitin Sahu
Date: Sat, 21 Mar 2026 18:03:45 +0530
Subject: [PATCH 14/92] fix: null out effect.b in destroy_effect to prevent
memory leak (#17980)
## Fix: #17881
### Root Cause
When a dynamic component switches (e.g. ` `), branch effects
are destroyed via `destroy_effect()`.
However, the `effect.b` (Boundary reference) field was not cleared
alongside other references (`next`, `prev`, `ctx`, `deps`, `fn`,
`nodes`, `ac`).
As a result, destroyed effects retained a reference to the `Boundary`
instance, which holds references to component state and child effects.
This prevented destroyed component subtrees from being garbage
collected, causing memory usage to grow during repeated component
switching.
### Solution
Clear the boundary reference during effect destruction.
```diff
effect.next =
effect.prev =
effect.teardown =
effect.ctx =
effect.deps =
effect.fn =
effect.nodes =
effect.ac =
+ effect.b =
null;
```
### Test Plan
* All existing tests pass:
* runtime-runes: 2,459
* runtime-legacy: 3,294
* signals: 96
* Total: 5,849 tests
* Added a dynamic component switching test that toggles components
repeatedly and verifies correct DOM output.
### Validation
Reproduced the issue using the example from #17881.
After applying the fix:
* Memory usage stabilizes during repeated component switching
* Destroyed components are properly reclaimed by the garbage collector
* No behavioral regressions observed
### Impact
* Fixes memory leak in dynamic component switching
* Minimal, safe change
* No API or behavior changes
---------
Co-authored-by: Rich Harris
---
.changeset/fix-memory-leak-boundary.md | 5 +++++
packages/svelte/src/internal/client/reactivity/effects.js | 1 +
2 files changed, 6 insertions(+)
create mode 100644 .changeset/fix-memory-leak-boundary.md
diff --git a/.changeset/fix-memory-leak-boundary.md b/.changeset/fix-memory-leak-boundary.md
new file mode 100644
index 0000000000..8a3b084981
--- /dev/null
+++ b/.changeset/fix-memory-leak-boundary.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: null out `effect.b` in `destroy_effect`
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index aeffeedddd..54c8a17d79 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -559,6 +559,7 @@ export function destroy_effect(effect, remove_dom = true) {
effect.fn =
effect.nodes =
effect.ac =
+ effect.b =
null;
}
From 7123bf3a131a53b1ba9414f0d44150a94d0132e3 Mon Sep 17 00:00:00 2001
From: Abishek Raj R R
Date: Sat, 21 Mar 2026 18:04:31 +0530
Subject: [PATCH 15/92] fix: remove trailing semicolon from {@const} tag
printer (#17962)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #17720
## Problem
The `{@const}` tag was being printed with a trailing semicolon,
producing invalid Svelte syntax like:
{@const a = 1;}
This happened because the `ConstTag` visitor in the printer was
delegating to esrap's `VariableDeclaration` handler, which always
appends a semicolon (correct for JS, but wrong for Svelte template
syntax).
## Solution
Instead of delegating to `VariableDeclaration`, the `ConstTag` visitor
now manually prints the tag by:
- Writing `{@const ` directly
- Iterating through declarators and visiting each one
- Separating multiple declarations with commas
- Closing with `}` — no trailing semicolon
## Before
{@const a = 1;}
{@const a = 1, b = 2;}
## After
{@const a = 1}
{@const a = 1, b = 2}
## Changes
- `packages/svelte/src/compiler/print/index.js` — fixed `ConstTag`
visitor
- `packages/svelte/tests/print/samples/const-tag/output.svelte` —
updated expected test output
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: Rich Harris
Co-authored-by: Rich Harris
---
.changeset/two-onions-end.md | 5 +++++
packages/svelte/src/compiler/print/index.js | 9 +++++++--
.../svelte/tests/print/samples/const-tag/output.svelte | 2 +-
3 files changed, 13 insertions(+), 3 deletions(-)
create mode 100644 .changeset/two-onions-end.md
diff --git a/.changeset/two-onions-end.md b/.changeset/two-onions-end.md
new file mode 100644
index 0000000000..9e6a56bbf9
--- /dev/null
+++ b/.changeset/two-onions-end.md
@@ -0,0 +1,5 @@
+---
+"svelte": patch
+---
+
+fix: remove trailing semicolon from {@const} tag printer
diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js
index 160aeb4345..26dc1b88e8 100644
--- a/packages/svelte/src/compiler/print/index.js
+++ b/packages/svelte/src/compiler/print/index.js
@@ -592,8 +592,13 @@ const svelte_visitors = (comments) => ({
},
ConstTag(node, context) {
- context.write('{@');
- context.visit(node.declaration);
+ context.write('{@const ');
+ const declarators = node.declaration.declarations;
+ for (let i = 0; i < declarators.length; i++) {
+ if (i > 0) context.write(', ');
+ context.visit(declarators[i]);
+ }
+
context.write('}');
},
diff --git a/packages/svelte/tests/print/samples/const-tag/output.svelte b/packages/svelte/tests/print/samples/const-tag/output.svelte
index d9d8addcbb..dded998a84 100644
--- a/packages/svelte/tests/print/samples/const-tag/output.svelte
+++ b/packages/svelte/tests/print/samples/const-tag/output.svelte
@@ -3,6 +3,6 @@
{#each boxes as box}
- {@const area = box.width * box.height;}
+ {@const area = box.width * box.height}
{box.width} * {box.height} = {area}
{/each}
From db69b7e345090edd4aa7949c52478adc05a61d61 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 21 Mar 2026 09:29:56 -0400
Subject: [PATCH 16/92] Version Packages (#17965)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.54.1
### Patch Changes
- fix: hydration comments during hmr
([#17975](https://github.com/sveltejs/svelte/pull/17975))
- fix: null out `effect.b` in `destroy_effect`
([#17980](https://github.com/sveltejs/svelte/pull/17980))
- fix: group sync statements
([#17977](https://github.com/sveltejs/svelte/pull/17977))
- fix: defer batch resolution until earlier intersecting batches have
committed ([#17162](https://github.com/sveltejs/svelte/pull/17162))
- fix: properly invoke `iterator.return()` during reactivity loss check
([#17966](https://github.com/sveltejs/svelte/pull/17966))
- fix: remove trailing semicolon from {@const} tag printer
([#17962](https://github.com/sveltejs/svelte/pull/17962))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/calm-mice-allow.md | 5 -----
.changeset/fix-memory-leak-boundary.md | 5 -----
.changeset/puny-masks-run.md | 5 -----
.changeset/spicy-teeth-tan.md | 5 -----
.changeset/tasty-carrots-tie.md | 5 -----
.changeset/two-onions-end.md | 5 -----
packages/svelte/CHANGELOG.md | 16 ++++++++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
9 files changed, 18 insertions(+), 32 deletions(-)
delete mode 100644 .changeset/calm-mice-allow.md
delete mode 100644 .changeset/fix-memory-leak-boundary.md
delete mode 100644 .changeset/puny-masks-run.md
delete mode 100644 .changeset/spicy-teeth-tan.md
delete mode 100644 .changeset/tasty-carrots-tie.md
delete mode 100644 .changeset/two-onions-end.md
diff --git a/.changeset/calm-mice-allow.md b/.changeset/calm-mice-allow.md
deleted file mode 100644
index eff71e1082..0000000000
--- a/.changeset/calm-mice-allow.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: hydration comments during hmr
diff --git a/.changeset/fix-memory-leak-boundary.md b/.changeset/fix-memory-leak-boundary.md
deleted file mode 100644
index 8a3b084981..0000000000
--- a/.changeset/fix-memory-leak-boundary.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: null out `effect.b` in `destroy_effect`
diff --git a/.changeset/puny-masks-run.md b/.changeset/puny-masks-run.md
deleted file mode 100644
index 162abe4ae7..0000000000
--- a/.changeset/puny-masks-run.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: group sync statements
diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md
deleted file mode 100644
index c7efa65130..0000000000
--- a/.changeset/spicy-teeth-tan.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: defer batch resolution until earlier intersecting batches have committed
diff --git a/.changeset/tasty-carrots-tie.md b/.changeset/tasty-carrots-tie.md
deleted file mode 100644
index 5d6cb8025d..0000000000
--- a/.changeset/tasty-carrots-tie.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: properly invoke `iterator.return()` during reactivity loss check
diff --git a/.changeset/two-onions-end.md b/.changeset/two-onions-end.md
deleted file mode 100644
index 9e6a56bbf9..0000000000
--- a/.changeset/two-onions-end.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"svelte": patch
----
-
-fix: remove trailing semicolon from {@const} tag printer
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 7c1311ae84..16cbf92ea4 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,21 @@
# svelte
+## 5.54.1
+
+### Patch Changes
+
+- fix: hydration comments during hmr ([#17975](https://github.com/sveltejs/svelte/pull/17975))
+
+- fix: null out `effect.b` in `destroy_effect` ([#17980](https://github.com/sveltejs/svelte/pull/17980))
+
+- fix: group sync statements ([#17977](https://github.com/sveltejs/svelte/pull/17977))
+
+- fix: defer batch resolution until earlier intersecting batches have committed ([#17162](https://github.com/sveltejs/svelte/pull/17162))
+
+- fix: properly invoke `iterator.return()` during reactivity loss check ([#17966](https://github.com/sveltejs/svelte/pull/17966))
+
+- fix: remove trailing semicolon from {@const} tag printer ([#17962](https://github.com/sveltejs/svelte/pull/17962))
+
## 5.54.0
### Minor Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 270d02d621..afbdfa956c 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.54.0",
+ "version": "5.54.1",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 191d53016a..7de1242e62 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.54.0';
+export const VERSION = '5.54.1';
export const PUBLIC_VERSION = '5';
From 0c669d9bc98f7a7dfc5da666deaf4f672e07c847 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=90=B4=E6=9D=A8=E5=B8=86?=
<39647285+leno23@users.noreply.github.com>
Date: Mon, 23 Mar 2026 01:09:14 +0800
Subject: [PATCH 17/92] docs: recommend createContext as the primary method for
context (#17959)
### 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
- [ ] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [ ] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [ ] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).
### Tests and linting
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
---------
Co-authored-by: Rich Harris
---
documentation/docs/06-runtime/02-context.md | 124 +++++++++++++++-----
1 file changed, 96 insertions(+), 28 deletions(-)
diff --git a/documentation/docs/06-runtime/02-context.md b/documentation/docs/06-runtime/02-context.md
index 61b203f93e..86009c9a20 100644
--- a/documentation/docs/06-runtime/02-context.md
+++ b/documentation/docs/06-runtime/02-context.md
@@ -2,7 +2,66 @@
title: Context
---
-Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling'). The parent component sets context with `setContext(key, value)`...
+Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling').
+
+By creating a `[get, set]` pair of functions with `createContext`, you can set the context in a parent component and get it in a child component:
+
+
+```svelte
+
+
+
+
+
+
+```
+
+```svelte
+
+
+
+{@render children()}
+```
+
+```svelte
+
+
+
+hello {user.name}, inside Child.svelte
+```
+
+```ts
+/// file: context.ts
+import { createContext } from 'svelte';
+
+interface User {
+ name: string;
+}
+
+export const [getUserContext, setUserContext] = createContext();
+```
+
+
+> [!NOTE] `createContext` was added in version 5.40. If you are using an earlier version of Svelte, you must use `setContext` and `getContext` instead.
+
+This is particularly useful when `Parent.svelte` is not directly aware of `Child.svelte`, but instead renders it as part of a `children` [snippet](snippet) as shown above.
+
+## `setContext` and `getContext`
+
+As an alternative to `createContext`, you can use `setContext` and `getContext` directly. The parent component sets context with `setContext(key, value)`...
```svelte
@@ -26,32 +85,28 @@ Context allows components to access values owned by parent components without pa
{message}, inside Child.svelte
```
-This is particularly useful when `Parent.svelte` is not directly aware of `Child.svelte`, but instead renders it as part of a `children` [snippet](snippet) ([demo](/playground/untitled#H4sIAAAAAAAAE42Q3W6DMAyFX8WyJgESK-oto6hTX2D3YxcM3IIUQpR40yqUd58CrCXsp7tL7HNsf2dAWXaEKR56yfTBGOOxFWQwfR6Qz8q1XAHjL-GjUhvzToJd7bU09FO9ctMkG0wxM5VuFeeFLLjtVK8ZnkpNkuGo-w6CTTJ9Z3PwsBAemlbUF934W8iy5DpaZtOUcU02-ZLcaS51jHEkTFm_kY1_wfOO8QnXrb8hBzDEc6pgZ4gFoyz4KgiD7nxfTe8ghqAhIfrJ46cTzVZBbkPlODVJsLCDO6V7ZcJoncyw1yRr0hd1GNn_ZbEM3I9i1bmVxOlWElUvDUNHxpQngt3C4CXzjS1rtvkw22wMrTRtTbC8Lkuabe7jvthPPe3DofYCAAA=)):
-
-```svelte
-
-
-
-```
-
The key (`'my-context'`, in the example above) and the context itself can be any JavaScript value.
+> [!NOTE] `createContext` is preferred since it provides better type safety and makes it unnecessary to use keys.
+
In addition to [`setContext`](svelte#setContext) and [`getContext`](svelte#getContext), Svelte exposes [`hasContext`](svelte#hasContext) and [`getAllContexts`](svelte#getAllContexts) functions.
## Using context with state
-You can store reactive state in context ([demo](/playground/untitled#H4sIAAAAAAAAE41R0W6DMAz8FSuaBNUQdK8MkKZ-wh7HHihzu6hgosRMm1D-fUpSVNq12x4iEvvOx_kmQU2PIhfP3DCCJGgHYvxkkYid7NCI_GUS_KUcxhVEMjOelErNB3bsatvG4LW6n0ZsRC4K02qpuKqpZtmrQTNMYJA3QRAs7PTQQxS40eMCt3mX3duxnWb-lS5h7nTI0A4jMWoo4c44P_Hku-zrOazdy64chWo-ScfRkRgl8wgHKrLTH1OxHZkHgoHaTraHcopXUFYzPPVfuC_hwQaD1GrskdiNCdQwJljJqlvXfyqVsA5CGg0uRUQifHw56xFtciO75QrP07vo_JXf_tf8yK2ezDKY_ZWt_1y2qqYzv7bI1IW1V_sN19m-07wCAAA=))...
+You can store reactive state in context...
+
```svelte
+
counter.count += 1}>
@@ -61,12 +116,39 @@ You can store reactive state in context ([demo](/playground/untitled#H4sIAAAAAAA
+
+ counter.count = 0}>
+ reset
+
+```
+
+```svelte
+
+
+
+{counter.count}
+```
+
+```ts
+/// file: context.ts
+import { createContext } from 'svelte';
+
+interface Counter {
+ count: number;
+}
+
+export const [getCounter, setCounter] = createContext();
```
+
...though note that if you _reassign_ `counter` instead of updating it, you will 'break the link' — in other words instead of this...
```svelte
- counter = { count: 0 }}>
+ counter = { count: 0 } }>
reset
```
@@ -81,21 +163,7 @@ You can store reactive state in context ([demo](/playground/untitled#H4sIAAAAAAA
Svelte will warn you if you get it wrong.
-## Type-safe context
-
-As an alternative to using `setContext` and `getContext` directly, you can use them via `createContext`. This gives you type safety and makes it unnecessary to use a key:
-
-```ts
-/// file: context.ts
-// @filename: ambient.d.ts
-interface User {}
-
-// @filename: index.ts
-// ---cut---
-import { createContext } from 'svelte';
-
-export const [getUserContext, setUserContext] = createContext();
-```
+## Component testing
When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing:
From a94924b67721e8b3151ebf1622691b1902c9bf85 Mon Sep 17 00:00:00 2001
From: moaaz <145723871+moaaz-ae@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:29:52 +0200
Subject: [PATCH 18/92] fix: export TweenOptions, SpringOptions,
SpringUpdateOptions and Updater from svelte/motion (#17967)
Exports `TweenedOptions`, `SpringOpts`, `SpringUpdateOpts`, and
`Updater` from `svelte/motion`.
These types are required for the public method signatures of `spring`
and `tweened` (e.g., as parameters for `.set()` and `.update()`). This
PR makes them accessible to TypeScript users, following the established
pattern in modules like `svelte/store` and `svelte/transition`.
Internal implementation details like `TickContext` remain private as
they do not appear in any public-facing signatures.
Fixes #16151
### 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: Rich Harris
Co-authored-by: Rich Harris
---
.changeset/angry-knives-bow.md | 5 ++
packages/svelte/src/motion/private.d.ts | 34 ---------
packages/svelte/src/motion/public.d.ts | 49 ++++++++++---
packages/svelte/src/motion/spring.js | 14 ++--
packages/svelte/src/motion/tweened.js | 15 ++--
packages/svelte/tests/types/motion.ts | 22 ++++++
packages/svelte/types/index.d.ts | 91 +++++++++++++------------
7 files changed, 128 insertions(+), 102 deletions(-)
create mode 100644 .changeset/angry-knives-bow.md
create mode 100644 packages/svelte/tests/types/motion.ts
diff --git a/.changeset/angry-knives-bow.md b/.changeset/angry-knives-bow.md
new file mode 100644
index 0000000000..c96b397886
--- /dev/null
+++ b/.changeset/angry-knives-bow.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: export TweenOptions, SpringOptions, SpringUpdateOptions and Updater from svelte/motion
diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts
index 22b8cc4af3..a6e0f95284 100644
--- a/packages/svelte/src/motion/private.d.ts
+++ b/packages/svelte/src/motion/private.d.ts
@@ -8,37 +8,3 @@ export interface TickContext {
};
settled: boolean;
}
-
-export interface SpringOpts {
- stiffness?: number;
- damping?: number;
- precision?: number;
-}
-
-export interface SpringUpdateOpts {
- /**
- * @deprecated Only use this for the spring store; does nothing when set on the Spring class
- */
- hard?: any;
- /**
- * @deprecated Only use this for the spring store; does nothing when set on the Spring class
- */
- soft?: string | number | boolean;
- /**
- * Only use this for the Spring class; does nothing when set on the spring store
- */
- instant?: boolean;
- /**
- * Only use this for the Spring class; does nothing when set on the spring store
- */
- preserveMomentum?: number;
-}
-
-export type Updater = (target_value: T, value: T) => T;
-
-export interface TweenedOptions {
- delay?: number;
- duration?: number | ((from: T, to: T) => number);
- easing?: (t: number) => number;
- interpolate?: (a: T, b: T) => (t: number) => T;
-}
diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts
index 4e74d4b76f..89145d0383 100644
--- a/packages/svelte/src/motion/public.d.ts
+++ b/packages/svelte/src/motion/public.d.ts
@@ -1,16 +1,49 @@
import { Readable, type Unsubscriber } from '../store/public.js';
-import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private.js';
+
+export interface SpringOptions {
+ stiffness?: number;
+ damping?: number;
+ precision?: number;
+}
+
+export interface SpringUpdateOptions {
+ /**
+ * @deprecated Only use this for the spring store; does nothing when set on the Spring class
+ */
+ hard?: any;
+ /**
+ * @deprecated Only use this for the spring store; does nothing when set on the Spring class
+ */
+ soft?: string | number | boolean;
+ /**
+ * Only use this for the Spring class; does nothing when set on the spring store
+ */
+ instant?: boolean;
+ /**
+ * Only use this for the Spring class; does nothing when set on the spring store
+ */
+ preserveMomentum?: number;
+}
+
+export type Updater = (target_value: T, value: T) => T;
+
+export interface TweenOptions {
+ delay?: number;
+ duration?: number | ((from: T, to: T) => number);
+ easing?: (t: number) => number;
+ interpolate?: (a: T, b: T) => (t: number) => T;
+}
// TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface)
// this means both the Spring class and the Spring interface are merged into one with some things only
// existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js
export interface Spring extends Readable {
- set(new_value: T, opts?: SpringUpdateOpts): Promise;
+ set(new_value: T, opts?: SpringUpdateOptions): Promise;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
- update: (fn: Updater, opts?: SpringUpdateOpts) => Promise;
+ update: (fn: Updater, opts?: SpringUpdateOptions) => Promise;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
@@ -37,7 +70,7 @@ export interface Spring extends Readable {
* @since 5.8.0
*/
export class Spring {
- constructor(value: T, options?: SpringOpts);
+ constructor(value: T, options?: SpringOptions);
/**
* Create a spring whose value is bound to the return value of `fn`. This must be called
@@ -53,7 +86,7 @@ export class Spring {
*
* ```
*/
- static of(fn: () => U, options?: SpringOpts): Spring;
+ static of(fn: () => U, options?: SpringOptions): Spring;
/**
* Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it.
@@ -63,7 +96,7 @@ export class Spring {
* If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*/
- set(value: T, options?: SpringUpdateOpts): Promise;
+ set(value: T, options?: SpringUpdateOptions): Promise;
damping: number;
precision: number;
@@ -81,8 +114,8 @@ export class Spring {
}
export interface Tweened extends Readable {
- set(value: T, opts?: TweenedOptions): Promise;
- update(updater: Updater, opts?: TweenedOptions): Promise;
+ set(value: T, opts?: TweenOptions): Promise;
+ update(updater: Updater, opts?: TweenOptions): Promise;
}
export { prefersReducedMotion, spring, tweened, Tween } from './index.js';
diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js
index 44be1a501b..7198d60127 100644
--- a/packages/svelte/src/motion/spring.js
+++ b/packages/svelte/src/motion/spring.js
@@ -1,6 +1,6 @@
/** @import { Task } from '#client' */
-/** @import { SpringOpts, SpringUpdateOpts, TickContext } from './private.js' */
-/** @import { Spring as SpringStore } from './public.js' */
+/** @import { TickContext } from './private.js' */
+/** @import { Spring as SpringStore, SpringOptions, SpringUpdateOptions } from './public.js' */
import { writable } from '../store/shared/index.js';
import { loop } from '../internal/client/loop.js';
import { raf } from '../internal/client/timing.js';
@@ -62,7 +62,7 @@ function tick_spring(ctx, last_value, current_value, target_value) {
* @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead
* @template [T=any]
* @param {T} [value]
- * @param {SpringOpts} [opts]
+ * @param {SpringOptions} [opts]
* @returns {SpringStore}
*/
export function spring(value, opts = {}) {
@@ -83,7 +83,7 @@ export function spring(value, opts = {}) {
let cancel_task = false;
/**
* @param {T} new_value
- * @param {SpringUpdateOpts} opts
+ * @param {SpringUpdateOptions} opts
* @returns {Promise}
*/
function set(new_value, opts = {}) {
@@ -191,7 +191,7 @@ export class Spring {
/**
* @param {T} value
- * @param {SpringOpts} [options]
+ * @param {SpringOptions} [options]
*/
constructor(value, options = {}) {
this.#current = DEV ? tag(state(value), 'Spring.current') : state(value);
@@ -225,7 +225,7 @@ export class Spring {
* ```
* @template U
* @param {() => U} fn
- * @param {SpringOpts} [options]
+ * @param {SpringOptions} [options]
*/
static of(fn, options) {
const spring = new Spring(fn(), options);
@@ -293,7 +293,7 @@ export class Spring {
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*
* @param {T} value
- * @param {SpringUpdateOpts} [options]
+ * @param {SpringUpdateOptions} [options]
*/
set(value, options) {
this.#deferred?.reject(new Error('Aborted'));
diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js
index 437c22ec3b..a24148d075 100644
--- a/packages/svelte/src/motion/tweened.js
+++ b/packages/svelte/src/motion/tweened.js
@@ -1,6 +1,5 @@
/** @import { Task } from '../internal/client/types' */
-/** @import { Tweened } from './public' */
-/** @import { TweenedOptions } from './private' */
+/** @import { Tweened, TweenOptions } from './public' */
import { writable } from '../store/shared/index.js';
import { raf } from '../internal/client/timing.js';
import { loop } from '../internal/client/loop.js';
@@ -84,7 +83,7 @@ function get_interpolator(a, b) {
* @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead
* @template T
* @param {T} [value]
- * @param {TweenedOptions} [defaults]
+ * @param {TweenOptions} [defaults]
* @returns {Tweened}
*/
export function tweened(value, defaults = {}) {
@@ -94,7 +93,7 @@ export function tweened(value, defaults = {}) {
let target_value = value;
/**
* @param {T} new_value
- * @param {TweenedOptions} [opts]
+ * @param {TweenOptions} [opts]
*/
function set(new_value, opts) {
target_value = new_value;
@@ -180,7 +179,7 @@ export class Tween {
#current;
#target;
- /** @type {TweenedOptions} */
+ /** @type {TweenOptions} */
#defaults;
/** @type {import('../internal/client/types').Task | null} */
@@ -188,7 +187,7 @@ export class Tween {
/**
* @param {T} value
- * @param {TweenedOptions} options
+ * @param {TweenOptions} options
*/
constructor(value, options = {}) {
this.#current = state(value);
@@ -216,7 +215,7 @@ export class Tween {
* ```
* @template U
* @param {() => U} fn
- * @param {TweenedOptions} [options]
+ * @param {TweenOptions} [options]
*/
static of(fn, options) {
const tween = new Tween(fn(), options);
@@ -233,7 +232,7 @@ export class Tween {
*
* If `options` are provided, they will override the tween's defaults.
* @param {T} value
- * @param {TweenedOptions} [options]
+ * @param {TweenOptions} [options]
* @returns
*/
set(value, options) {
diff --git a/packages/svelte/tests/types/motion.ts b/packages/svelte/tests/types/motion.ts
new file mode 100644
index 0000000000..0ba5d1d9c6
--- /dev/null
+++ b/packages/svelte/tests/types/motion.ts
@@ -0,0 +1,22 @@
+import {
+ type TweenOptions,
+ type SpringOptions,
+ type SpringUpdateOptions,
+ type Updater
+} from 'svelte/motion';
+
+let tweenOptions: TweenOptions = {
+ delay: 100,
+ duration: 400
+};
+
+let springOptions: SpringOptions = {
+ stiffness: 0.1,
+ damping: 0.5
+};
+
+let springUpdateOptions: SpringUpdateOptions = {
+ instant: true
+};
+
+let updater: Updater = (target, value) => target + value;
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 371a823225..019baf45dd 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -2005,16 +2005,50 @@ declare module 'svelte/legacy' {
declare module 'svelte/motion' {
import type { MediaQuery } from 'svelte/reactivity';
+ export interface SpringOptions {
+ stiffness?: number;
+ damping?: number;
+ precision?: number;
+ }
+
+ export interface SpringUpdateOptions {
+ /**
+ * @deprecated Only use this for the spring store; does nothing when set on the Spring class
+ */
+ hard?: any;
+ /**
+ * @deprecated Only use this for the spring store; does nothing when set on the Spring class
+ */
+ soft?: string | number | boolean;
+ /**
+ * Only use this for the Spring class; does nothing when set on the spring store
+ */
+ instant?: boolean;
+ /**
+ * Only use this for the Spring class; does nothing when set on the spring store
+ */
+ preserveMomentum?: number;
+ }
+
+ export type Updater = (target_value: T, value: T) => T;
+
+ export interface TweenOptions {
+ delay?: number;
+ duration?: number | ((from: T, to: T) => number);
+ easing?: (t: number) => number;
+ interpolate?: (a: T, b: T) => (t: number) => T;
+ }
+
// TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface)
// this means both the Spring class and the Spring interface are merged into one with some things only
// existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js
export interface Spring extends Readable {
- set(new_value: T, opts?: SpringUpdateOpts): Promise;
+ set(new_value: T, opts?: SpringUpdateOptions): Promise;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
- update: (fn: Updater, opts?: SpringUpdateOpts) => Promise;
+ update: (fn: Updater, opts?: SpringUpdateOptions) => Promise;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
@@ -2041,7 +2075,7 @@ declare module 'svelte/motion' {
* @since 5.8.0
*/
export class Spring {
- constructor(value: T, options?: SpringOpts);
+ constructor(value: T, options?: SpringOptions);
/**
* Create a spring whose value is bound to the return value of `fn`. This must be called
@@ -2057,7 +2091,7 @@ declare module 'svelte/motion' {
*
* ```
*/
- static of(fn: () => U, options?: SpringOpts): Spring;
+ static of(fn: () => U, options?: SpringOptions): Spring;
/**
* Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it.
@@ -2067,7 +2101,7 @@ declare module 'svelte/motion' {
* If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*/
- set(value: T, options?: SpringUpdateOpts): Promise;
+ set(value: T, options?: SpringUpdateOptions): Promise;
damping: number;
precision: number;
@@ -2085,8 +2119,8 @@ declare module 'svelte/motion' {
}
export interface Tweened extends Readable {
- set(value: T, opts?: TweenedOptions): Promise;
- update(updater: Updater, opts?: TweenedOptions): Promise;
+ set(value: T, opts?: TweenOptions): Promise;
+ update(updater: Updater, opts?: TweenOptions): Promise;
}
/** Callback to inform of a value updates. */
type Subscriber = (value: T) => void;
@@ -2103,39 +2137,6 @@ declare module 'svelte/motion' {
*/
subscribe(this: void, run: Subscriber, invalidate?: () => void): Unsubscriber;
}
- interface SpringOpts {
- stiffness?: number;
- damping?: number;
- precision?: number;
- }
-
- interface SpringUpdateOpts {
- /**
- * @deprecated Only use this for the spring store; does nothing when set on the Spring class
- */
- hard?: any;
- /**
- * @deprecated Only use this for the spring store; does nothing when set on the Spring class
- */
- soft?: string | number | boolean;
- /**
- * Only use this for the Spring class; does nothing when set on the spring store
- */
- instant?: boolean;
- /**
- * Only use this for the Spring class; does nothing when set on the spring store
- */
- preserveMomentum?: number;
- }
-
- type Updater = (target_value: T, value: T) => T;
-
- interface TweenedOptions {
- delay?: number;
- duration?: number | ((from: T, to: T) => number);
- easing?: (t: number) => number;
- interpolate?: (a: T, b: T) => (t: number) => T;
- }
/**
* A [media query](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery) that matches if the user [prefers reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).
*
@@ -2165,13 +2166,13 @@ declare module 'svelte/motion' {
*
* @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead
* */
- export function spring(value?: T | undefined, opts?: SpringOpts | undefined): Spring;
+ export function spring(value?: T | undefined, opts?: SpringOptions | undefined): Spring;
/**
* A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time.
*
* @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead
* */
- export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): Tweened;
+ export function tweened(value?: T | undefined, defaults?: TweenOptions | undefined): Tweened;
/**
* A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to
* move towards it over time, taking account of the `delay`, `duration` and `easing` options.
@@ -2204,15 +2205,15 @@ declare module 'svelte/motion' {
* ```
*
*/
- static of(fn: () => U, options?: TweenedOptions | undefined): Tween;
+ static of(fn: () => U, options?: TweenOptions | undefined): Tween;
- constructor(value: T, options?: TweenedOptions);
+ constructor(value: T, options?: TweenOptions);
/**
* Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it.
*
* If `options` are provided, they will override the tween's defaults.
* */
- set(value: T, options?: TweenedOptions | undefined): Promise;
+ set(value: T, options?: TweenOptions | undefined): Promise;
get current(): T;
set target(v: T);
get target(): T;
From b90a5dfb61545a547f7c5891fc23052c5ff251ea Mon Sep 17 00:00:00 2001
From: Razin Shafayet
Date: Mon, 23 Mar 2026 00:29:08 +0600
Subject: [PATCH 19/92] docs: clarify `$derived` `await` behavior and
dependency tracking (#17696)
Closes #17660
### 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.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [ ] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).
### Tests and linting
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
---------
Co-authored-by: Rich Harris
---
documentation/docs/02-runes/03-$derived.md | 11 ++++++++++
.../19-await-expressions.md | 21 ++++++++++++-------
2 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/documentation/docs/02-runes/03-$derived.md b/documentation/docs/02-runes/03-$derived.md
index 35cb6c1912..f85ba90baa 100644
--- a/documentation/docs/02-runes/03-$derived.md
+++ b/documentation/docs/02-runes/03-$derived.md
@@ -51,6 +51,17 @@ In essence, `$derived(expression)` is equivalent to `$derived.by(() => expressio
Anything read synchronously inside the `$derived` expression (or `$derived.by` function body) is considered a _dependency_ of the derived state. When the state changes, the derived will be marked as _dirty_ and recalculated when it is next read.
+In addition, if an expression contains an [`await`](await-expressions), Svelte transforms it such that any state _after_ the `await` is also tracked — in other words, in a case like this...
+
+```js
+let a = Promise.resolve(1);
+let b = 2;
+// ---cut---
+let total = $derived(await a + b);
+```
+
+...both `a` and `b` are tracked, even though `b` is only read once `a` has resolved, after the initial execution. (This does not apply to `await` in functions that are called by the expression, only the expression itself.)
+
To exempt a piece of state from being treated as a dependency, use [`untrack`](svelte#untrack).
## Overriding derived values
diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md
index 2f73f6a47c..aa3f907b14 100644
--- a/documentation/docs/03-template-syntax/19-await-expressions.md
+++ b/documentation/docs/03-template-syntax/19-await-expressions.md
@@ -59,8 +59,8 @@ Updates can overlap — a fast update will be reflected in the UI while an earli
Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup...
```svelte
-{await one()}
-{await two()}
+{await one(x)}
+{await two(y)}
```
...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential.
@@ -68,13 +68,18 @@ Svelte will do as much asynchronous work as it can in parallel. For example if y
This does not apply to sequential `await` expressions inside your `
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+ {a++;b++;}}>
+ a and b
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js
new file mode 100644
index 0000000000..f4aff83aad
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js
@@ -0,0 +1,82 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ await tick();
+ const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button');
+
+ a_b.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ b_d.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ pop.click(); // second b resolved, blocked on first batch because a still pending
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ for (let i = 0; i < 3; i++) {
+ pop.click(); // second a resolved, first a/b now obsolete; empty queue
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 2 | b 2 | c 1 | d 1
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+ }
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte
new file mode 100644
index 0000000000..25dbe586ca
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte
@@ -0,0 +1,26 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+ {a++;b++;}}>
+ a and b
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js
new file mode 100644
index 0000000000..c9e7513b22
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js
@@ -0,0 +1,108 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ await tick();
+ const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button');
+
+ a_b.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ b_d.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // first a resolved, still pending: [b, a, b]
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ pop.click(); // second b resolved, still pending: [b, a]
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // first b resolved, first + last batch settled, still pending: [a]
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 0 | d 1
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // all resolved
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 2 | b 2 | c 1 | d 1
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte
new file mode 100644
index 0000000000..25dbe586ca
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte
@@ -0,0 +1,26 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+ {a++;b++;}}>
+ a and b
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js
new file mode 100644
index 0000000000..472a3ebcaf
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js
@@ -0,0 +1,110 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ await tick();
+ const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button');
+
+ a_b.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ b_d.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // first a resolved, still pending: [b, a, b]
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ pop.click(); // second b resolved, still pending: [b, a]
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ pop.click(); // second a resolved, first a/b now obsolete
+ // TODO would be nice to show final result here already, right now it doesn't because
+ // we have no handle on the already resolved first a anymore
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // queue empty
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 2 | b 2 | c 1 | d 1
+ a and b
+ a and c
+ b and d
+ shift
+ pop
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte
new file mode 100644
index 0000000000..25dbe586ca
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte
@@ -0,0 +1,26 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+ {a++;b++;}}>
+ a and b
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js
new file mode 100644
index 0000000000..d03f3cfbb8
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js
@@ -0,0 +1,118 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a, c, shift, pop] = target.querySelectorAll('button');
+
+ a.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ // how it's on main
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 1 | d 1
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 1 | d 3
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ // how it's on https://github.com/sveltejs/svelte/pull/17971
+ // shift.click();
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 0 | b 0 | c 0 | d 0
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+
+ // shift.click();
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 1 | b 2 | c 0 | d 2
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+
+ // shift.click();
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 1 | b 2 | c 1 | d 3
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte
new file mode 100644
index 0000000000..cf028718c2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte
@@ -0,0 +1,23 @@
+
+
+a {a} | b {b} | c {c} | d {d}
+ {a++;}}>
+ a++
+
+ {c++;}}>
+ c++
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js
new file mode 100644
index 0000000000..27152889a2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js
@@ -0,0 +1,76 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a, c, shift, pop] = target.querySelectorAll('button');
+
+ a.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ // Although the second batch is eventually connected to the first one, we can't see that
+ // at this point yet and so the second one flushes right away.
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 1 | d 1
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 1 | d 1
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 1 | d 3
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte
new file mode 100644
index 0000000000..cf028718c2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte
@@ -0,0 +1,23 @@
+
+
+a {a} | b {b} | c {c} | d {d}
+ {a++;}}>
+ a++
+
+ {c++;}}>
+ c++
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js
new file mode 100644
index 0000000000..b1bf53a2fc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js
@@ -0,0 +1,129 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a, c, shift, pop] = target.querySelectorAll('button');
+
+ a.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ c.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // schedules second step of first batch and schedules rerun of second batch
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 0 | b 0 | c 0 | d 0
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ // how it's on main
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 0 | d 2
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // obsolete second batch promise (already rejected)
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 0 | d 2
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ shift.click(); // first batch resolves
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ a 1 | b 2 | c 1 | d 3
+ a++
+ c++
+ shift
+ pop
+ `
+ );
+
+ // how it's on https://github.com/sveltejs/svelte/pull/17971
+ // pop.click(); // second batch resolves but knows it needs to wait on first batch
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 0 | b 0 | c 0 | d 0
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+
+ // shift.click(); // obsolete second batch promise (already rejected)
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 0 | b 0 | c 0 | d 0
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+
+ // shift.click(); // first batch resolves, with it second can now resolve as well
+ // await tick();
+ // assert.htmlEqual(
+ // target.innerHTML,
+ // `
+ // a 1 | b 2 | c 1 | d 3
+ // a++
+ // c++
+ // shift
+ // pop
+ // `
+ // );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte
new file mode 100644
index 0000000000..cf028718c2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte
@@ -0,0 +1,23 @@
+
+
+a {a} | b {b} | c {c} | d {d}
+ {a++;}}>
+ a++
+
+ {c++;}}>
+ c++
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js
new file mode 100644
index 0000000000..07ce7e340f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js
@@ -0,0 +1,34 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a_b_fork, a_c, shift, pop, commit] = target.querySelectorAll('button');
+ const [p] = target.querySelectorAll('p');
+
+ a_b_fork.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0');
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0');
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1');
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte
new file mode 100644
index 0000000000..60ba74fb8e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte
@@ -0,0 +1,27 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c}
+
+ {f = fork(() => {a++;b++;});}}>
+ a and b (fork)
+
+ {a++;c++;}}>
+ a and c
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit fork
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js
new file mode 100644
index 0000000000..180a190dae
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js
@@ -0,0 +1,42 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button');
+ const [p] = target.querySelectorAll('p');
+
+ a_b_fork.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ b_d.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1');
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte
new file mode 100644
index 0000000000..6d1c4ab418
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte
@@ -0,0 +1,31 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+
+ {f = fork(() => {a++;b++;});}}>
+ a and b (fork)
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit fork
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js
new file mode 100644
index 0000000000..61360d8dc9
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js
@@ -0,0 +1,43 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true,
+ async test({ assert, target }) {
+ await tick();
+ const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button');
+ const [p] = target.querySelectorAll('p');
+
+ a_b_fork.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ a_c.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ b_d.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1 | d 0');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte
new file mode 100644
index 0000000000..6d1c4ab418
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte
@@ -0,0 +1,31 @@
+
+
+a {await delay(a)} | b {await delay(b)} | c {c} | d {d}
+
+ {f = fork(() => {a++;b++;});}}>
+ a and b (fork)
+
+ {a++;c++;}}>
+ a and c
+
+ {b++;d++;}}>
+ b and d
+
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit fork
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte
new file mode 100644
index 0000000000..6b765526c8
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte
@@ -0,0 +1,7 @@
+
+
+{x}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js
new file mode 100644
index 0000000000..0af275009c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js
@@ -0,0 +1,38 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target, logs }) {
+ const [x, y, resolve] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ ` // if this shows world world - that would also be ok
+ );
+
+ resolve.click();
+ await tick();
+ assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ universe
+ universe
+ universe
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte
new file mode 100644
index 0000000000..8edc718de2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte
@@ -0,0 +1,28 @@
+
+
+ x = 'universe'}>x
+
+ y++}>y++
+
+ deferred.shift()()}>resolve
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js
new file mode 100644
index 0000000000..035616dfb6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js
@@ -0,0 +1,49 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, resolve] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+
+ ` // if this shows world world "world" world world world "world" - then this would also be ok
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte
new file mode 100644
index 0000000000..11c4bdf5d1
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte
@@ -0,0 +1,30 @@
+
+
+ x = 'universe'}>x
+
+ y++}>y++
+
+ deferred.shift()()}>resolve
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js
new file mode 100644
index 0000000000..a2d615b6e5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js
@@ -0,0 +1,61 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, resolve] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+
+ `
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+
+ ` // if this shows world world "world" world world world "world" - then this would also be ok
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte
new file mode 100644
index 0000000000..b02ab20995
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte
@@ -0,0 +1,34 @@
+
+
+ (x = 'universe')}>x
+
+ y++}>y++
+ deferred.pop()?.()}>resolve
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
+
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js
new file mode 100644
index 0000000000..8b7e7784de
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js
@@ -0,0 +1,171 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO more combinations pass on https://github.com/sveltejs/svelte/pull/17971
+ timeout: 20_000,
+ async test({ assert, target }) {
+ const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] =
+ target.querySelectorAll('button');
+
+ const initial = `
+ x
+ x (fork)
+ y++
+ y++ (fork)
+ shift
+ pop
+ commit x
+ commit y
+ reset
+
+ `;
+
+ const final = `
+ x
+ x (fork)
+ y++
+ y++ (fork)
+ shift
+ pop
+ commit x
+ commit y
+ reset
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `;
+
+ /** @param {HTMLElement} button */
+ async function click(button) {
+ button.click();
+ await tick();
+ }
+
+ /**
+ * Generate all permutations of an array.
+ * @param {HTMLElement[]} actions
+ * @returns {HTMLElement[][]}
+ */
+ function permutations(actions) {
+ if (actions.length <= 1) return [actions];
+
+ /** @type {HTMLElement[][]} */
+ const result = [];
+
+ for (let i = 0; i < actions.length; i++) {
+ const head = actions[i];
+ const rest = actions.slice(0, i).concat(actions.slice(i + 1));
+ for (const tail of permutations(rest)) {
+ result.push([head, ...tail]);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Keep only valid orders where fork commits happen after their fork action.
+ * @param {HTMLElement[]} order
+ */
+ function is_valid_order(order) {
+ const x_fork_index = order.indexOf(fork_x);
+ const commit_x_index = order.indexOf(commit_x);
+ if (commit_x_index !== -1 && (x_fork_index === -1 || commit_x_index < x_fork_index)) {
+ return false;
+ }
+
+ const y_fork_index = order.indexOf(fork_y);
+ const commit_y_index = order.indexOf(commit_y);
+ if (commit_y_index !== -1 && (y_fork_index === -1 || commit_y_index < y_fork_index)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Four control scenarios:
+ * - x direct, y direct
+ * - x direct, y via fork+commit
+ * - x via fork+commit, y direct
+ * - x via fork+commit, y via fork+commit
+ */
+ const control_scenarios = [
+ [x, y],
+ [x, fork_y, commit_y],
+ [fork_x, commit_x, y],
+ [fork_x, commit_x, fork_y, commit_y]
+ ];
+
+ const control_orders = control_scenarios.flatMap((scenario) =>
+ permutations(scenario).filter(is_valid_order)
+ );
+
+ /**
+ * All shift/pop combinations for draining async work.
+ * We click three times because this scenario can queue up to 3 deferred resolutions.
+ */
+ const resolve_orders = [
+ [shift, shift, shift],
+ [shift, pop, pop],
+ [pop, shift, shift],
+ [pop, pop, pop]
+ ];
+
+ for (const controls of control_orders) {
+ for (const resolves of resolve_orders) {
+ for (const action of controls) {
+ await click(action);
+ }
+
+ for (const action of resolves) {
+ await click(action);
+ }
+
+ const failure_msg = `Failed for: ${controls
+ .map((btn) => btn.textContent)
+ .concat(...resolves.map((btn) => btn.textContent))
+ .join(', ')}`;
+ assert.htmlEqual(target.innerHTML, final, failure_msg);
+
+ await click(reset);
+ assert.htmlEqual(target.innerHTML, initial, failure_msg);
+ }
+ }
+
+ const other_scenarios = [
+ [x, shift, y, shift, shift],
+ [x, shift, y, pop, pop],
+ [fork_x, shift, y, shift, commit_x, shift],
+ [fork_x, shift, y, pop, commit_x, pop],
+ [y, shift, x, shift, shift],
+ [y, shift, x, pop, pop],
+ [fork_y, shift, x, shift, commit_y, shift],
+ [fork_y, shift, x, pop, commit_y, pop]
+ ];
+
+ for (const scenario of other_scenarios) {
+ for (const action of scenario) {
+ await click(action);
+ }
+
+ const failure_msg = `Failed for: ${scenario.map((btn) => btn.textContent).join(', ')}`;
+ assert.htmlEqual(target.innerHTML, final, failure_msg);
+
+ await click(reset);
+ assert.htmlEqual(target.innerHTML, initial, failure_msg);
+ }
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte
new file mode 100644
index 0000000000..bb821d9b0f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte
@@ -0,0 +1,41 @@
+
+
+ (x = 'universe')}>x
+ (fx = fork(() => {x = 'universe';}))}>x (fork)
+ y++}>y++
+ (fy = fork(() => {y++;}))}>y++ (fork)
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ fx.commit()}>commit x
+ fy.commit()}>commit y
+ {x = 'world'; y = 0;}}>reset
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js
new file mode 100644
index 0000000000..737169cb91
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js
@@ -0,0 +1,90 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, shift, pop, commit] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte
new file mode 100644
index 0000000000..5f8d5efa13
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte
@@ -0,0 +1,36 @@
+
+
+ (f = fork(() => {x = 'universe';}))}>x
+ y++}>y++
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js
new file mode 100644
index 0000000000..a712e70630
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js
@@ -0,0 +1,71 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, shift, pop, commit] = target.querySelectorAll('button');
+
+ y.click();
+ await tick();
+
+ x.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte
new file mode 100644
index 0000000000..5f8d5efa13
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte
@@ -0,0 +1,36 @@
+
+
+ (f = fork(() => {x = 'universe';}))}>x
+ y++}>y++
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js
new file mode 100644
index 0000000000..905e76511b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js
@@ -0,0 +1,70 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [x, y, shift, pop, commit] = target.querySelectorAll('button');
+
+ y.click();
+ await tick();
+
+ x.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+
+ `
+ );
+
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ shift
+ pop
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte
new file mode 100644
index 0000000000..5f8d5efa13
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte
@@ -0,0 +1,36 @@
+
+
+ (f = fork(() => {x = 'universe';}))}>x
+ y++}>y++
+ deferred.shift()?.()}>shift
+ deferred.pop()?.()}>pop
+ f.commit()}>commit
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js
new file mode 100644
index 0000000000..6a6399a19e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js
@@ -0,0 +1,76 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, resolve, commit] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+
+ world
+ "world"
+ world
+ world
+ world
+ "world"
+ `
+ );
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+
+ world
+ "world"
+ world
+ world
+ world
+ "world"
+ `
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte
new file mode 100644
index 0000000000..5f00fc4dec
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte
@@ -0,0 +1,32 @@
+
+
+ (f = fork(() => {x = 'universe';}))}>x
+
+ y++}>y++
+ deferred.pop()?.()}>resolve
+ f.commit()}>commit
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte
new file mode 100644
index 0000000000..f8c01e9efd
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte
@@ -0,0 +1,11 @@
+
+
+
+{x}
+{JSON.stringify(x)}
+{#if x === 'universe'}universe{:else}world{/if}
+{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if}
+{await Promise.resolve(x)}
+{await Promise.resolve(JSON.stringify(x))}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js
new file mode 100644
index 0000000000..9221a96c2e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js
@@ -0,0 +1,85 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
+ async test({ assert, target }) {
+ const [x, y, resolve, commit] = target.querySelectorAll('button');
+
+ x.click();
+ await tick();
+
+ y.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+
+ `
+ );
+
+ commit.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+
+ `
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+
+ world
+ "world"
+ world
+ world
+ world
+ "world"
+ `
+ );
+
+ resolve.click();
+ await tick();
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ x
+ y++
+ resolve
+ commit
+ universe
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+
+ universe
+ "universe"
+ universe
+ universe
+ universe
+ "universe"
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte
new file mode 100644
index 0000000000..5575e3cbd4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte
@@ -0,0 +1,36 @@
+
+
+ (f = fork(() => {x = 'universe';}))}>x
+
+ y++}>y++
+ deferred.pop()?.()}>resolve
+ f.commit()}>commit
+
+{#if x === 'universe'}
+ {await delay(x)}
+
+{/if}
+
+
+
+{#if y > 0}
+
+{/if}
diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts
index bbd252b8e1..af36f16e29 100644
--- a/packages/svelte/tests/suite.ts
+++ b/packages/svelte/tests/suite.ts
@@ -4,6 +4,7 @@ import { it } from 'vitest';
export interface BaseTest {
skip?: boolean;
solo?: boolean;
+ timeout?: number;
}
/**
@@ -30,7 +31,7 @@ export function suite(fn: (config: Test, test_dir: string
await for_each_dir(cwd, samples_dir, (config, dir) => {
let it_fn = config.skip ? it.skip : config.solo ? it.only : it;
- it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`));
+ it_fn(dir, { timeout: config.timeout }, () => fn(config, `${cwd}/${samples_dir}/${dir}`));
});
}
};
@@ -57,7 +58,7 @@ export function suite_with_variants {
+ it_fn(`${dir} (${variant})`, { timeout: config.timeout }, async () => {
if (!called_common) {
called_common = true;
common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`);
From 54ba176d2c9068f2e6df9249764bb7766ec1b69d Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 24 Mar 2026 11:31:17 -0400
Subject: [PATCH 23/92] docs: 'open in playground' links (#17983)
This takes advantage of https://github.com/sveltejs/svelte.dev/pull/1879
and https://github.com/sveltejs/svelte.dev/pull/1880 to replace some of
the hardcoded playground links in the docs with ones that are generated
automatically from the code. This is a better user experience, and
easier to maintain.
Not every playground link is suitable for replacement, as in many cases
we gloss over implementation details. For now I'm just starting with
some easy ones
---
documentation/docs/02-runes/04-$effect.md | 49 ++++++-
documentation/docs/02-runes/05-$props.md | 25 +++-
documentation/docs/02-runes/07-$inspect.md | 10 +-
.../docs/03-template-syntax/03-each.md | 19 ++-
.../docs/03-template-syntax/06-snippet.md | 128 ++++++++++++++++--
.../docs/03-template-syntax/12-bind.md | 22 ++-
.../19-await-expressions.md | 5 +-
documentation/docs/06-runtime/02-context.md | 2 +-
8 files changed, 235 insertions(+), 25 deletions(-)
diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md
index d41c5b8e6a..a13fc7bc46 100644
--- a/documentation/docs/02-runes/04-$effect.md
+++ b/documentation/docs/02-runes/04-$effect.md
@@ -41,9 +41,11 @@ You can use `$effect` anywhere, not just at the top level of a component, as lon
> [!NOTE] Svelte uses effects internally to represent logic and expressions in your template — this is how `hello {name}! ` updates when `name` changes.
-An effect can return a _teardown function_ which will run immediately before the effect re-runs ([demo](/playground/untitled#H4sIAAAAAAAAE42SQVODMBCF_8pOxkPRKq3HCsx49K4n64xpskjGkDDJ0tph-O8uINo6HjxB3u7HvrehE07WKDbiyZEhi1osRWksRrF57gQdm6E2CKx_dd43zU3co6VB28mIf-nKO0JH_BmRRRVMQ8XWbXkAgfKtI8jhIpIkXKySu7lSG2tNRGZ1_GlYr1ZTD3ddYFmiosUigbyAbpC2lKbwWJkIB8ZhhxBQBWRSw6FCh3sM8GrYTthL-wqqku4N44TyqEgwF3lmRHr4Op0PGXoH31c5rO8mqV-eOZ49bikgtcHBL55tmhIkEMqg_cFB2TpFxjtg703we6NRL8HQFCS07oSUCZi6Rm04lz1yytIHBKoQpo1w6Gsm4gmyS8b8Y5PydeMdX8gwS2Ok4I-ov5NZtvQde95GMsccn_1wzNKfu3RZtS66cSl9lvL7qO1aIk7knbJGvefdtIOzi73M4bYvovUHDFk6AcX_0HRESxnpBOW_jfCDxIZCi_1L_wm4xGQ60wIAAA==)).
+An effect can return a _teardown function_ which will run immediately before the effect re-runs:
+
```svelte
+
+
a++}>a++
b++}>b++
@@ -236,6 +254,7 @@ When using [`await`](await-expressions) in components, the `$effect.pending()` r
pending promises: {$effect.pending()}
{/if}
```
+
## `$effect.root`
@@ -285,9 +304,11 @@ In general, `$effect` is best considered something of an escape hatch — useful
If you're using an effect because you want to be able to reassign the derived value (to build an optimistic UI, for example) note that [deriveds can be directly overridden]($derived#Overriding-derived-values) as of Svelte 5.25.
-You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Don't use effects for this ([demo](/playground/untitled#H4sIAAAAAAAAE5WRTWrDMBCFryKGLBJoY3fRjWIHeoiu6i6UZBwEY0VE49TB-O6VxrFTSih0qe_Ne_OjHpxpEDS8O7ZMeIAnqC1hAP3RA1990hKI_Fb55v06XJA4sZ0J-IjvT47RcYyBIuzP1vO2chVHHFjxiQ2pUr3k-SZRQlbBx_LIFoEN4zJfzQph_UMQr4hRXmBd456Xy5Uqt6pPKHmkfmzyPAZL2PCnbRpg8qWYu63I7lu4gswOSRYqrPNt3CgeqqzgbNwRK1A76w76YqjFspfcQTWmK3vJHlQm1puSTVSeqdOc_r9GaeCHfUSY26TXry6Br4RSK3C6yMEGT-aqVU3YbUZ2NF6rfP2KzXgbuYzY46czdgyazy0On_FlLH3F-UDXhgIO35UGlA1rAgAA)):
+You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Instead of using effects for this...
+
```svelte
+
+
+
+```
```svelte
@@ -163,6 +179,7 @@ The fallback value of a prop not declared with `$bindable` is left untouched —
clicks: {object.count}
```
+
In summary: don't mutate props. Either use callback props to communicate changes, or — if parent and child should share the same object — use the [`$bindable`]($bindable) rune.
diff --git a/documentation/docs/02-runes/07-$inspect.md b/documentation/docs/02-runes/07-$inspect.md
index f67e250b45..00857f3ef4 100644
--- a/documentation/docs/02-runes/07-$inspect.md
+++ b/documentation/docs/02-runes/07-$inspect.md
@@ -5,9 +5,11 @@ tags: rune-inspect
> [!NOTE] `$inspect` only works during development. In a production build it becomes a noop.
-The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire ([demo](/playground/untitled#H4sIAAAAAAAACkWQ0YqDQAxFfyUMhSotdZ-tCvu431AXtGOqQ2NmmMm0LOK_r7Utfby5JzeXTOpiCIPKT5PidkSVq2_n1F7Jn3uIcEMSXHSw0evHpAjaGydVzbUQCmgbWaCETZBWMPlKj29nxBDaHj_edkAiu12JhdkYDg61JGvE_s2nR8gyuBuiJZuDJTyQ7eE-IEOzog1YD80Lb0APLfdYc5F9qnFxjiKWwbImo6_llKRQVs-2u91c_bD2OCJLkT3JZasw7KLA2XCX31qKWE6vIzNk1fKE0XbmYrBTufiI8-_8D2cUWBA_AQAA)):
+The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire:
+
```svelte
+
@@ -71,6 +73,7 @@ Snippets can be declared anywhere inside your component. They can reference valu
{@render hello('alice')}
{@render hello('bob')}
```
+
...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
@@ -91,9 +94,11 @@ Snippets can be declared anywhere inside your component. They can reference valu
{@render x()}
```
-Snippets can reference themselves and each other ([demo](/playground/untitled#H4sIAAAAAAAAE2WPTQqDMBCFrxLiRqH1Zysi7TlqF1YnENBJSGJLCYGeo5tesUeosfYH3c2bee_jjaWMd6BpfrAU6x5oTvdS0g01V-mFPkNnYNRaDKrxGxto5FKCIaeu1kYwFkauwsoUWtZYPh_3W5FMY4U2mb3egL9kIwY0rbhgiO-sDTgjSEqSTvIDs-jiOP7i_MHuFGAL6p9BtiSbOTl0GtzCuihqE87cqtyam6WRGz_vRcsZh5bmRg3gju4Fptq_kzQBAAA=)):
+Snippets can reference themselves and each other:
+
```svelte
+
{#snippet blastoff()}
🚀
{/snippet}
@@ -109,14 +114,17 @@ Snippets can reference themselves and each other ([demo](/playground/untitled#H4
{@render countdown(10)}
```
+
## Passing snippets to components
### Explicit props
-Within the template, snippets are values just like any other. As such, they can be passed to components as props ([demo](/playground/untitled#H4sIAAAAAAAAE3VS247aMBD9lZGpBGwDASRegonaPvQL2qdlH5zYEKvBNvbQLbL875VzAcKyj3PmzJnLGU8UOwqSkd8KJdaCk4TsZS0cyV49wYuJuQiQpGd-N2bu_ooaI1YwJ57hpVYoFDqSEepKKw3mO7VDeTTaIvxiRS1gb_URxvO0ibrS8WanIrHUyiHs7Vmigy28RmyHHmKvDMbMmFq4cQInvGSwTsBYWYoMVhCSB2rBFFPsyl0uruTlR3JZCWvlTXl1Yy_mawiR_rbZKZrellJ-5JQ0RiBUgnFhJ9OGR7HKmwVoilXeIye8DOJGfYCgRlZ3iE876TBsZPX7hPdteO75PC4QaIo8vwNPePmANQ2fMeEFHrLD7rR1jTNkW986E8C3KwfwVr8HSHOSEBT_kGRozyIkn_zQveXDL3rIfPJHtUDwzShJd_Qk3gQCbOGLsdq4yfTRJopRuin3I7nv6kL7ARRjmLdBDG3uv1mhuLA3V2mKtqNEf_oCn8p9aN-WYqH5peP4kWBl1UwJzAEPT9U7K--0fRrrWnPTXpCm1_EVdXjpNmlA8G1hPPyM1fKgMqjFHjctXGjLhZ05w0qpDhksGrybuNEHtJnCalZWsuaTlfq6nPaaBSv_HKw-K57BjzOiVj9ZKQYKzQjZodYFqydYTRN4gPhVzTDO2xnma3HsVWjaLjT8nbfwHy7Q5f2dBAAA)):
+Within the template, snippets are values just like any other. As such, they can be passed to components as props:
+
```svelte
+
+
+
+ {#if header}
+
+ {@render header()}
+
+ {/if}
+
+
+ {#each data as d}
+ {@render row(d)}
+ {/each}
+
+
+
+
```
+
Think about it like passing content instead of data to a component. The concept is similar to slots in web components.
### Implicit props
-As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component ([demo](/playground/untitled#H4sIAAAAAAAAE3VSTa_aMBD8Kyu_SkAbCA-JSzBR20N_QXt6vIMTO8SqsY29tI2s_PcqTiB8vaPHs7MzuxuIZgdBMvJLo0QlOElIJZXwJHsLBBvb_XUASc7Mb9Yu_B-hsMMK5sUzvDQahUZPMkJ96aTFfKd3KA_WOISfrFACKmcOMFmk8TWUTjY73RFLoz1C5U4SPWzhrcN2GKDrlcGEWauEnyRwxCaDdQLWyVJksII2uaMWTDPNLtzX5YX8-kgua-GcHJVXI3u5WEPb0d83O03TMZSmfRzOkG1Db7mNacOL19JagVALxoWbztq-H8U6j0SaYp2P2BGbOyQ2v8PQIFMXLKRDk177pq0zf6d8bMrzwBdd0pamyPMb-IjNEzS2f86Gz_Dwf-2F9nvNSUJQ_EOSoTuJNvngqK5v4Pas7n4-OCwlEEJcQTIMO-nSQwtb-GSdsX46e9gbRoP9yGQ11I0rEuycunu6PHx1QnPhxm3SFN15MOlYEFJZtf0dUywMbwZOeBGsrKNLYB54-1R9WNqVdki7usim6VmQphf7mnpshiQRhNAXdoOfMyX3OgMlKtz0cGEcF27uLSul3mewjPjgOOoDukxjPS9rqfh0pb-8zs6aBSt_7505aZ7B9xOi0T9YKW4UooVsr0zB1BTrWQJ3EL-oWcZ572GxFoezCk37QLe3897-B2i2U62uBAAA)):
+As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component:
+
```svelte
-
+
+
+
{#snippet header()}
fruit
@@ -169,12 +225,54 @@ As an authoring convenience, snippets declared directly _inside_ a component imp
```
+```svelte
+
+
+
+
+ {#if header}
+
+ {@render header()}
+
+ {/if}
+
+
+ {#each data as d}
+ {@render row(d)}
+ {/each}
+
+
+
+
+```
+
+
### Implicit `children` snippet
-Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet ([demo](/playground/untitled#H4sIAAAAAAAAE3WOQQrCMBBFrzIMggql3ddY1Du4si5sOmIwnYRkFKX07lKqglqX8_7_w2uRDw1hjlsWI5ZqTPBoLEXMdy3K3fdZDzB5Ndfep_FKVnpWHSKNce1YiCVijirqYLwUJQOYxrsgsLmIOIZjcA1M02w4n-PpomSVvTclqyEutDX6DA2pZ7_ABIVugrmEC3XJH92P55_G39GodCmWBFrQJ2PrQAwdLGHig_NxNv9xrQa1dhWIawrv1Wzeqawa8953D-8QOmaEAQAA)):
+Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet:
+
```svelte
+
+
click me
```
@@ -187,6 +285,7 @@ Any content inside the component tags that is _not_ a snippet declaration implic
{@render children()}
```
+
> [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name
@@ -256,9 +355,21 @@ We can tighten things up further by declaring a generic, so that `data` and `row
## Exporting snippets
-Snippets declared at the top level of a `.svelte` file can be exported from a `
+
+{@render add(1, 2)}
+
+```
```svelte
+
@@ -267,6 +378,7 @@ Snippets declared at the top level of a `.svelte` file can be exported from a `<
{a} + {b} = {a + b}
{/snippet}
```
+
> [!NOTE]
> This requires Svelte 5.5.0 or newer
diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md
index be84969b87..e8164149db 100644
--- a/documentation/docs/03-template-syntax/12-bind.md
+++ b/documentation/docs/03-template-syntax/12-bind.md
@@ -54,9 +54,11 @@ A `bind:value` directive on an ` ` element binds the input's `value` prope
{message}
```
-In the case of a numeric input (`type="number"` or `type="range"`), the value will be coerced to a number ([demo](/playground/untitled#H4sIAAAAAAAAE6WPwYoCMQxAfyWEPeyiOOqx2w74Hds9pBql0IllmhGXYf5dKqwiyILsLXnwwsuI-5i4oPkaUX8yo7kCnKNQV7dNzoty4qSVBSr8jG-Poixa0KAt2z5mbb14TaxA4OCtKCm_rz4-f2m403WltrlrYhMFTtcLNkoeFGqZ8yhDF7j3CCHKzpwoDexGmqCL4jwuPUJHZ-dxVcfmyYGe5MAv-La5pbxYFf5Z9Zf_UJXb-sEMquFgJJhBmGyTW5yj8lnRaD_w9D1dAKSSj7zqAQAA)):
+In the case of a numeric input (`type="number"` or `type="range"`), the value will be coerced to a number:
+
```svelte
+
+Customize your burrito
+
Plain
Whole wheat
@@ -165,7 +171,17 @@ Inputs that work together can use `bind:group` ([demo](/playground/untitled#H4sI
Beans
Cheese
Guac (extra)
+
+Tortilla: {tortilla}
+Fillings: {fillings.join(', ') || 'None'}
+
+
```
+
> [!NOTE] `bind:group` only works if the inputs are in the same Svelte component.
diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md
index aa3f907b14..04437f5ee3 100644
--- a/documentation/docs/03-template-syntax/19-await-expressions.md
+++ b/documentation/docs/03-template-syntax/19-await-expressions.md
@@ -25,9 +25,11 @@ The experimental flag will be removed in Svelte 6.
## Synchronized updates
-When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like [this](/playground/untitled#H4sIAAAAAAAAE42QsWrDQBBEf2VZUkhYRE4gjSwJ0qVMkS6XYk9awcFpJe5Wdoy4fw-ycdykSPt2dpiZFYVGxgrf2PsJTlPwPWTcO-U-xwIH5zli9bminudNtwEsbl-v8_wYj-x1Y5Yi_8W7SZRFI1ZYxy64WVsjRj0rEDTwEJWUs6f8cKP2Tp8vVIxSPEsHwyKdukmA-j6jAmwO63Y1SidyCsIneA_T6CJn2ZBD00Jk_XAjT4tmQwEv-32eH6AsgYK6wXWOPPTs6Xy1CaxLECDYgb3kSUbq8p5aaifzorCt0RiUZbQcDIJ10ldH8gs3K6X2Xzqbro5zu1KCHaw2QQPrtclvwVSXc2sEC1T-Vqw0LJy-ClRy_uSkx2ogHzn9ADZ1CubKAQAA)...
+When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like this...
+
```svelte
+
+
+
+
{@html await firstTest()}
+ {await otherTest()}
+
From a9d8439ad1eecde5c4a91dca1eb1f034abfe7d81 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Sat, 28 Mar 2026 00:02:04 +0100
Subject: [PATCH 25/92] fix: reschedule new effects in prior batches (#18021)
If a batch creates a new branch (e.g. through an if block becoming true)
the previous batches so far do not know about the new effects created
through that. This can lead to stale values being shown. We therefore
schedule those new effects on prior batches if they are touched by a
`current` value of that batch
Fixes #17099
extracted from #17971
---
.changeset/yellow-hairs-laugh.md | 5 +++
.../src/internal/client/reactivity/batch.js | 32 +++++++++++++++++++
.../src/internal/client/reactivity/effects.js | 4 ++-
.../async-state-new-branch-1/_config.js | 13 ++++++--
.../async-state-new-branch-2/_config.js | 9 ++++--
.../async-state-new-branch-3/_config.js | 11 +++++--
.../async-state-new-branch-3/main.svelte | 1 -
.../async-state-new-branch-fork-2/_config.js | 3 +-
.../async-state-new-branch-fork-5/_config.js | 1 -
.../samples/async-state-new-branch/_config.js | 1 -
10 files changed, 68 insertions(+), 12 deletions(-)
create mode 100644 .changeset/yellow-hairs-laugh.md
diff --git a/.changeset/yellow-hairs-laugh.md b/.changeset/yellow-hairs-laugh.md
new file mode 100644
index 0000000000..1c1ede8dc6
--- /dev/null
+++ b/.changeset/yellow-hairs-laugh.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reschedule new effects in prior batches
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 3b10d6ebe6..d53d824d03 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -145,6 +145,12 @@ export class Batch {
*/
#roots = [];
+ /**
+ * Effects created while this batch was active.
+ * @type {Effect[]}
+ */
+ #new_effects = [];
+
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set}
@@ -472,6 +478,13 @@ export class Batch {
batches.delete(this);
}
+ /**
+ * @param {Effect} effect
+ */
+ register_created_effect(effect) {
+ this.#new_effects.push(effect);
+ }
+
#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
@@ -525,6 +538,25 @@ export class Batch {
mark_effects(source, others, marked, checked);
}
+ 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
+ );
+
+ 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);
+ }
+ }
+ }
+
// Only apply and traverse when we know we triggered async work with marking the effects
if (batch.#roots.length > 0) {
batch.apply();
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 54c8a17d79..ea8a4b645e 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -42,7 +42,7 @@ import { DEV } from 'esm-env';
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 } from './batch.js';
+import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@@ -120,6 +120,8 @@ function create_effect(type, fn) {
effect.component_function = dev_current_component_function;
}
+ current_batch?.register_created_effect(effect);
+
/** @type {Effect | null} */
var e = effect;
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js
index 0af275009c..dee8af2446 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target, logs }) {
const [x, y, resolve] = target.querySelectorAll('button');
@@ -17,12 +16,20 @@ export default test({
x
y++
resolve
- ` // if this shows world world - that would also be ok
+ world
+ ` // if this does not show world - that would also be ok
);
resolve.click();
await tick();
- assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
+ assert.deepEqual(logs, [
+ 'universe',
+ 'world',
+ '$effect: world',
+ '$effect: universe',
+ '$effect: universe'
+ ]);
+ // assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok
assert.htmlEqual(
target.innerHTML,
`
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js
index 035616dfb6..d99f0df731 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
@@ -18,7 +17,13 @@ export default test({
y++
resolve
- ` // if this shows world world "world" world world world "world" - then this would also be ok
+ world
+ "world"
+ world
+ world
+ world
+ "world"
+ ` // if this does not show world "world" world world world "world" - then this would also be ok
);
resolve.click();
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js
index a2d615b6e5..eb4485e8a6 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
@@ -30,9 +29,17 @@ export default test({
y++
resolve
- ` // if this shows world world "world" world world world "world" - then this would also be ok
+ world
+ "world"
+ world
+ world
+ world
+ "world"
+ ` // if this does not show world "world" world world world "world" - then this would also be ok
);
+ resolve.click();
+ await tick();
resolve.click();
await tick();
assert.htmlEqual(
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte
index b02ab20995..c8a4ca587f 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte
@@ -31,4 +31,3 @@
{#if y > 0}
{/if}
-
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js
index a712e70630..74df968d82 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, shift, pop, commit] = target.querySelectorAll('button');
@@ -43,6 +42,8 @@ export default test({
await tick();
shift.click();
await tick();
+ shift.click(); // would be ok to not need this one
+ await tick();
assert.htmlEqual(
target.innerHTML,
`
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js
index 9221a96c2e..e8f16ade3c 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve, commit] = target.querySelectorAll('button');
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js
index f2091eb6ab..f4b6cc777b 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js
@@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- skip: true, // this fails on main, too; skip for now
async test({ assert, target, logs }) {
const [x, y, resolve] = target.querySelectorAll('button');
From 957f2755faaeeecb6774d2c57ec4b0d7b622453e Mon Sep 17 00:00:00 2001
From: Paolo Ricciuti
Date: Sat, 28 Mar 2026 17:30:48 +0100
Subject: [PATCH 26/92] fix: cleanup `superTypeParameters` in
`ClassDeclarations`/`ClassExpression` (#18015)
Closes #18012
We were removing `superTypeArguments` but the AST showed
`superTypeParameters`.
Luckily, I was able to pinpoint `esrap@2.2.4` as the cause... I've only
bumped `esrap` and that made the test fail (so that I could fix it)
And of course it was that...there's literally a commit that explicitly
print them lol
https://github.com/sveltejs/esrap/commit/f9137c41016ed86dbfe0d82e88ce54a5c9ab47ad
---
.changeset/lucky-animals-take.md | 5 +++++
packages/svelte/package.json | 2 +-
.../phases/1-parse/remove_typescript_nodes.js | 2 ++
.../runtime-runes/samples/typescript/main.svelte | 5 +++++
pnpm-lock.yaml | 11 ++++++-----
5 files changed, 19 insertions(+), 6 deletions(-)
create mode 100644 .changeset/lucky-animals-take.md
diff --git a/.changeset/lucky-animals-take.md b/.changeset/lucky-animals-take.md
new file mode 100644
index 0000000000..b249fa9f78
--- /dev/null
+++ b/.changeset/lucky-animals-take.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: cleanup `superTypeParameters` in `ClassDeclarations`/`ClassExpression`
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 7b35924e15..25fae9f262 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -178,7 +178,7 @@
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
- "esrap": "^2.2.2",
+ "esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
diff --git a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js
index 0835c5fc79..438d6fe48f 100644
--- a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js
+++ b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js
@@ -138,11 +138,13 @@ const visitors = {
delete node.abstract;
delete node.implements;
delete node.superTypeArguments;
+ delete node.superTypeParameters;
return context.next();
},
ClassExpression(node, context) {
delete node.implements;
delete node.superTypeArguments;
+ delete node.superTypeParameters;
return context.next();
},
MethodDefinition(node, context) {
diff --git a/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte b/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte
index 4fc7c4ec38..0286f2c0fd 100644
--- a/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte
@@ -27,6 +27,11 @@
abstract x(): void;
y() {}
}
+ class Subclass extends Foo {
+ constructor(value: string) {
+ super(value);
+ }
+ }
declare const declared_const: number;
declare function declared_fn(): void;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aa423e4923..2987749c2a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -102,8 +102,8 @@ importers:
specifier: ^1.2.1
version: 1.2.1
esrap:
- specifier: ^2.2.2
- version: 2.2.2
+ specifier: ^2.2.4
+ version: 2.2.4
is-reference:
specifier: ^3.0.3
version: 3.0.3
@@ -1418,8 +1418,8 @@ packages:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
- esrap@2.2.2:
- resolution: {integrity: sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==}
+ esrap@2.2.4:
+ resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
@@ -3794,9 +3794,10 @@ snapshots:
dependencies:
estraverse: 5.3.0
- esrap@2.2.2:
+ esrap@2.2.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
+ '@typescript-eslint/types': 8.56.0
esrecurse@4.3.0:
dependencies:
From 04eadbc8a9ea905878fec90e61b5a62a2f9a4045 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Sun, 29 Mar 2026 21:56:27 +0200
Subject: [PATCH 27/92] fix: correctly handle bindings on the server (#18009)
`item.subsume(item)` did nothing, honestly kinda weird how this only
turned up now
Fixes #17981
---
.changeset/eight-trees-occur.md | 5 +++++
packages/svelte/src/internal/server/renderer.js | 10 +++++++---
.../samples/async-hydration-binding/Async.svelte | 7 +++++++
.../samples/async-hydration-binding/Binding.svelte | 7 +++++++
.../samples/async-hydration-binding/Bound.svelte | 0
.../samples/async-hydration-binding/_config.js | 10 ++++++++++
.../samples/async-hydration-binding/main.svelte | 7 +++++++
7 files changed, 43 insertions(+), 3 deletions(-)
create mode 100644 .changeset/eight-trees-occur.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte
diff --git a/.changeset/eight-trees-occur.md b/.changeset/eight-trees-occur.md
new file mode 100644
index 0000000000..cc200b39ba
--- /dev/null
+++ b/.changeset/eight-trees-occur.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correctly handle bindings on the server
diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js
index da65811eca..4bcb45512e 100644
--- a/packages/svelte/src/internal/server/renderer.js
+++ b/packages/svelte/src/internal/server/renderer.js
@@ -468,10 +468,14 @@ export class Renderer {
}
this.local = other.local;
- this.#out = other.#out.map((item) => {
- if (item instanceof Renderer) {
- item.subsume(item);
+ this.#out = other.#out.map((item, i) => {
+ const current = this.#out[i];
+
+ if (current instanceof Renderer && item instanceof Renderer) {
+ current.subsume(item);
+ return current;
}
+
return item;
});
this.promise = other.promise;
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte
new file mode 100644
index 0000000000..6e7dbc82bb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte
@@ -0,0 +1,7 @@
+
+
+
+ {data}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte
new file mode 100644
index 0000000000..3deba21acc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js
new file mode 100644
index 0000000000..d4d36b007f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js
@@ -0,0 +1,10 @@
+import { test } from '../../test';
+
+// Tests that renderer.subsume (which is used when bindings are present) works correctly
+export default test({
+ mode: ['hydrate'],
+ html: 'test
',
+ async test({ assert, warnings }) {
+ assert.deepEqual(warnings, []);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte
new file mode 100644
index 0000000000..a38806be7c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte
@@ -0,0 +1,7 @@
+
+
+
+
From 4879f9da999c2919a60e5bd60afd89042adc7ea7 Mon Sep 17 00:00:00 2001
From: Rohit Nair P
Date: Mon, 30 Mar 2026 01:34:18 +0530
Subject: [PATCH 28/92] fix: improve duplicate module import error message
(#18016)
## Description
**While investigating the compiler analysis phase for errors or missing
lines, I located an explicit `TODO fix the message here` comment in the
source code
(`packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js`).**
### Triggering Code
```svelte
```
## The Bug
When a component author creates a local `let` declaration in the
instance `
+
+
+
+{#if true}
+ {@const m1 = message}
+ {@const m2 = (() => m1)()}
+
+ {m1}
+ {m2}
+{/if}
From 99b1467ba4d48de9eb85beec4dbc329b98b94d71 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 1 Apr 2026 11:58:18 -0400
Subject: [PATCH 36/92] chore: generate CPU profiles when running benchmarks
(#18043)
This causes `pnpm bench` and `pnpm bench:compare` to generate
`.cpuprofile` files for each benchmark, which should in theory make it
easier to understand how different branches affect performance (and find
opportunities for optimisation). These files can be opened directly in
VS Code and other editors, or in
[profiler.firefox.com](https://profiler.firefox.com),
[speedscope.app](https://www.speedscope.app), Chrome's performance
devtools and so on.
---
.github/workflows/ci.yml | 2 +-
.gitignore | 2 ++
benchmarking/compare/index.js | 19 ++++++++++++++++-
benchmarking/compare/runner.js | 7 ++++++-
benchmarking/run.js | 9 ++++++++-
benchmarking/utils.js | 37 ++++++++++++++++++++++++++++++++++
6 files changed, 72 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 78bab024ca..df9f755874 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,7 +107,7 @@ jobs:
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
- node-version: 18
+ node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm bench
diff --git a/.gitignore b/.gitignore
index d503437664..d3c1819bd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,6 @@ coverage
tmp
+benchmarking/.profiles
benchmarking/compare/.results
+benchmarking/compare/.profiles
diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js
index 8f38686a29..97a297d930 100644
--- a/benchmarking/compare/index.js
+++ b/benchmarking/compare/index.js
@@ -17,6 +17,10 @@ fs.mkdirSync(outdir);
const branches = [];
+let PROFILE_DIR = path.resolve(filename, '../.profiles');
+if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true });
+fs.mkdirSync(PROFILE_DIR, { recursive: true });
+
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--')) continue;
if (arg === filename) continue;
@@ -44,7 +48,12 @@ for (const branch of branches) {
execSync(`git checkout ${branch}`);
await new Promise((fulfil, reject) => {
- const child = fork(runner);
+ const child = fork(runner, [], {
+ env: {
+ ...process.env,
+ BENCH_PROFILE_DIR: `${PROFILE_DIR}/${safe(branch)}`
+ }
+ });
child.on('message', (results) => {
fs.writeFileSync(`${outdir}/${branch}.json`, JSON.stringify(results, null, ' '));
@@ -57,6 +66,10 @@ for (const branch of branches) {
console.groupEnd();
}
+if (PROFILE_DIR !== null) {
+ console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
+}
+
const results = branches.map((branch) => {
return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8'));
});
@@ -101,3 +114,7 @@ for (let i = 0; i < results[0].length; i += 1) {
function char(i) {
return String.fromCharCode(97 + i);
}
+
+function safe(name) {
+ return name.replace(/[^a-z0-9._-]+/gi, '_');
+}
diff --git a/benchmarking/compare/runner.js b/benchmarking/compare/runner.js
index 11e40ed983..31a8e6b44b 100644
--- a/benchmarking/compare/runner.js
+++ b/benchmarking/compare/runner.js
@@ -1,12 +1,17 @@
import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js';
+import { with_cpu_profile } from '../utils.js';
const results = [];
+const PROFILE_DIR = process.env.BENCH_PROFILE_DIR;
for (let i = 0; i < reactivity_benchmarks.length; i += 1) {
const benchmark = reactivity_benchmarks[i];
process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `);
- results.push({ benchmark: benchmark.label, ...(await benchmark.fn()) });
+ results.push({
+ benchmark: benchmark.label,
+ ...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()))
+ });
process.stderr.write('\x1b[2K\r');
}
diff --git a/benchmarking/run.js b/benchmarking/run.js
index 2b09f7c592..80e40a5ff1 100644
--- a/benchmarking/run.js
+++ b/benchmarking/run.js
@@ -1,10 +1,13 @@
import * as $ from '../packages/svelte/src/internal/client/index.js';
import { reactivity_benchmarks } from './benchmarks/reactivity/index.js';
import { ssr_benchmarks } from './benchmarks/ssr/index.js';
+import { with_cpu_profile } from './utils.js';
// e.g. `pnpm bench kairo` to only run the kairo benchmarks
const filters = process.argv.slice(2);
+const PROFILE_DIR = './benchmarking/.profiles';
+
const suites = [
{
benchmarks: reactivity_benchmarks.filter(
@@ -50,7 +53,7 @@ try {
console.log('='.repeat(TOTAL_WIDTH));
for (const benchmark of benchmarks) {
- const results = await benchmark.fn();
+ const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) +
@@ -70,6 +73,10 @@ try {
);
console.log('='.repeat(TOTAL_WIDTH));
}
+
+ if (PROFILE_DIR !== null) {
+ console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
+ }
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
diff --git a/benchmarking/utils.js b/benchmarking/utils.js
index 5581135e00..b363576963 100644
--- a/benchmarking/utils.js
+++ b/benchmarking/utils.js
@@ -1,4 +1,7 @@
import { performance, PerformanceObserver } from 'node:perf_hooks';
+import fs from 'node:fs';
+import path from 'node:path';
+import inspector from 'node:inspector/promises';
import v8 from 'v8-natives';
// Credit to https://github.com/milomg/js-reactivity-benchmark for the logic for timing + GC tracking.
@@ -41,3 +44,37 @@ export async function fastest_test(times, fn) {
return results.reduce((a, b) => (a.time < b.time ? a : b));
}
+
+function safe(name) {
+ return name.replace(/[^a-z0-9._-]+/gi, '_');
+}
+
+/**
+ * @template T
+ * @param {string | null} profile_dir
+ * @param {string} profile_name
+ * @param {() => T | Promise} fn
+ * @returns {Promise}
+ */
+export async function with_cpu_profile(profile_dir, profile_name, fn) {
+ if (profile_dir === null) {
+ return await fn();
+ }
+
+ fs.mkdirSync(profile_dir, { recursive: true });
+
+ const session = new inspector.Session();
+ session.connect();
+
+ await session.post('Profiler.enable');
+ await session.post('Profiler.start');
+
+ try {
+ return await fn();
+ } finally {
+ const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop'));
+ const file = path.join(profile_dir, `${safe(profile_name)}.cpuprofile`);
+ fs.writeFileSync(file, JSON.stringify(profile));
+ session.disconnect();
+ }
+}
From e402b2d0d678599a99e6fe03521f92ed922e5f27 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 1 Apr 2026 12:19:19 -0400
Subject: [PATCH 37/92] chore: write `pnpm bench:compare` report to disk
(#18045)
will give this a quick self-merge to unblock
---
benchmarking/compare/index.js | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js
index 97a297d930..dcf5d48dbd 100644
--- a/benchmarking/compare/index.js
+++ b/benchmarking/compare/index.js
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
const filename = fileURLToPath(import.meta.url);
const runner = path.resolve(filename, '../runner.js');
const outdir = path.resolve(filename, '../.results');
+const report_file = `${outdir}/report.txt`;
if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true });
fs.mkdirSync(outdir);
@@ -74,8 +75,15 @@ const results = branches.map((branch) => {
return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8'));
});
+fs.writeFileSync(report_file, '');
+
+const write = (str) => {
+ fs.appendFileSync(report_file, str + '\n');
+ console.log(str);
+};
+
for (let i = 0; i < results[0].length; i += 1) {
- console.group(`${results[0][i].benchmark}`);
+ write(`${results[0][i].benchmark}`);
for (const metric of ['time', 'gc_time']) {
const times = results.map((result) => +result[i][metric]);
@@ -97,18 +105,17 @@ for (let i = 0; i < results[0].length; i += 1) {
}
if (min !== 0) {
- console.group(`${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
+ write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
times.forEach((time, b) => {
const SIZE = 20;
const n = Math.round(SIZE * (time / max));
- console.log(`${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
+ write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
});
- console.groupEnd();
}
}
- console.groupEnd();
+ write('');
}
function char(i) {
From 345b8ed69f0b74b91d7ede4052e302c85e3f9cd1 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 1 Apr 2026 20:10:16 -0400
Subject: [PATCH 38/92] chore: keep previous benchmark runs around (#18049)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
extracted from #18047 – will self-merge this bit
---
benchmarking/compare/index.js | 65 +++++++++++++++++++++++++----------
benchmarking/utils.js | 2 +-
2 files changed, 47 insertions(+), 20 deletions(-)
diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js
index dcf5d48dbd..100d98a767 100644
--- a/benchmarking/compare/index.js
+++ b/benchmarking/compare/index.js
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { execSync, fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
+import { safe } from '../utils.js';
// if (execSync('git status --porcelain').toString().trim()) {
// console.error('Working directory is not clean');
@@ -13,46 +14,59 @@ const runner = path.resolve(filename, '../runner.js');
const outdir = path.resolve(filename, '../.results');
const report_file = `${outdir}/report.txt`;
-if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true });
-fs.mkdirSync(outdir);
+fs.mkdirSync(outdir, { recursive: true });
-const branches = [];
+const requested_branches = [];
let PROFILE_DIR = path.resolve(filename, '../.profiles');
-if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true });
fs.mkdirSync(PROFILE_DIR, { recursive: true });
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--')) continue;
if (arg === filename) continue;
- branches.push(arg);
+ requested_branches.push(arg);
}
-if (branches.length === 0) {
- branches.push(
+if (requested_branches.length === 0) {
+ requested_branches.push(
execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD').toString().trim()
);
}
-if (branches.length === 1) {
- branches.push('main');
+const original_ref = execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD')
+ .toString()
+ .trim();
+
+if (
+ requested_branches.length === 1 &&
+ !requested_branches.includes('main') &&
+ !fs.existsSync(`${outdir}/main.json`)
+) {
+ requested_branches.push('main');
}
process.on('exit', () => {
- execSync(`git checkout ${branches[0]}`);
+ execSync(`git checkout ${original_ref}`);
});
-for (const branch of branches) {
+for (const branch of requested_branches) {
console.group(`Benchmarking ${branch}`);
+ const branch_profile_dir = `${PROFILE_DIR}/${safe(branch)}`;
+ if (fs.existsSync(branch_profile_dir))
+ fs.rmSync(branch_profile_dir, { recursive: true, force: true });
+
+ const branch_result_file = `${outdir}/${branch}.json`;
+ if (fs.existsSync(branch_result_file)) fs.rmSync(branch_result_file, { force: true });
+
execSync(`git checkout ${branch}`);
await new Promise((fulfil, reject) => {
const child = fork(runner, [], {
env: {
...process.env,
- BENCH_PROFILE_DIR: `${PROFILE_DIR}/${safe(branch)}`
+ BENCH_PROFILE_DIR: branch_profile_dir
}
});
@@ -71,9 +85,20 @@ if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
}
-const results = branches.map((branch) => {
- return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8'));
-});
+const result_files = fs
+ .readdirSync(outdir)
+ .filter((file) => file.endsWith('.json'))
+ .sort((a, b) => a.localeCompare(b));
+
+const branches = result_files.map((file) => file.slice(0, -5));
+const results = result_files.map((file) =>
+ JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8'))
+);
+
+if (results.length === 0) {
+ console.error(`No result files found in ${outdir}`);
+ process.exit(1);
+}
fs.writeFileSync(report_file, '');
@@ -82,6 +107,12 @@ const write = (str) => {
console.log(str);
};
+for (let i = 0; i < branches.length; i += 1) {
+ write(`${char(i)}: ${branches[i]}`);
+}
+
+write('');
+
for (let i = 0; i < results[0].length; i += 1) {
write(`${results[0][i].benchmark}`);
@@ -121,7 +152,3 @@ for (let i = 0; i < results[0].length; i += 1) {
function char(i) {
return String.fromCharCode(97 + i);
}
-
-function safe(name) {
- return name.replace(/[^a-z0-9._-]+/gi, '_');
-}
diff --git a/benchmarking/utils.js b/benchmarking/utils.js
index b363576963..4821a167fe 100644
--- a/benchmarking/utils.js
+++ b/benchmarking/utils.js
@@ -45,7 +45,7 @@ export async function fastest_test(times, fn) {
return results.reduce((a, b) => (a.time < b.time ? a : b));
}
-function safe(name) {
+export function safe(name) {
return name.replace(/[^a-z0-9._-]+/gi, '_');
}
From c93e251654a3193f294d6a4171d4087b4cb2fb80 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 2 Apr 2026 08:42:41 -0400
Subject: [PATCH 39/92] fix: never set derived.v inside fork (#18037)
This started out as me implementing
https://github.com/sveltejs/svelte/pull/17998/changes#r3018047965, but
then I realised that I'd also fixed the bug that #17998 addresses. So I
guess it's an alternative to that PR
---------
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
---
.changeset/poor-tips-send.md | 5 +++++
.../src/internal/client/reactivity/batch.js | 21 +++++++++---------
.../internal/client/reactivity/deriveds.js | 8 ++++---
.../src/internal/client/reactivity/sources.js | 12 ++--------
.../async-fork-resolves-promise/_config.js | 22 +++++++++++++++++++
.../async-fork-resolves-promise/main.svelte | 21 ++++++++++++++++++
6 files changed, 65 insertions(+), 24 deletions(-)
create mode 100644 .changeset/poor-tips-send.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte
diff --git a/.changeset/poor-tips-send.md b/.changeset/poor-tips-send.md
new file mode 100644
index 0000000000..beb0329705
--- /dev/null
+++ b/.changeset/poor-tips-send.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: never set derived.v inside fork
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index d53d824d03..23cc73ffc8 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -419,18 +419,22 @@ export class Batch {
* Associate a change to a given source with the current
* batch, noting its previous and current values
* @param {Value} source
- * @param {any} old_value
+ * @param {any} value
* @param {boolean} [is_derived]
*/
- capture(source, old_value, is_derived = false) {
- if (old_value !== UNINITIALIZED && !this.previous.has(source)) {
- this.previous.set(source, old_value);
+ capture(source, value, is_derived = false) {
+ if (source.v !== UNINITIALIZED && !this.previous.has(source)) {
+ this.previous.set(source, source.v);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
- this.current.set(source, [source.v, is_derived]);
- batch_values?.set(source, source.v);
+ this.current.set(source, [value, is_derived]);
+ batch_values?.set(source, value);
+ }
+
+ if (!this.is_fork) {
+ source.v = value;
}
}
@@ -1162,11 +1166,6 @@ export function fork(fn) {
flushSync(fn);
- // revert state changes
- for (var [source, value] of batch.previous) {
- source.v = value;
- }
-
return {
commit: async () => {
if (committed) {
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 5da0df0670..77acb23516 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -384,7 +384,6 @@ export function execute_derived(derived) {
* @returns {void}
*/
export function update_derived(derived) {
- var old_value = derived.v;
var value = execute_derived(derived);
if (!derived.equals(value)) {
@@ -395,8 +394,11 @@ export function update_derived(derived) {
// otherwise, the next time we get here after a 'real world' state
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
- derived.v = value;
- current_batch?.capture(derived, old_value, true);
+ if (current_batch !== null) {
+ current_batch.capture(derived, value, true);
+ } else {
+ derived.v = value;
+ }
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 3ccde0f211..9235c5f673 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -180,18 +180,10 @@ export function set(source, value, should_proxy = false) {
*/
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
- var old_value = source.v;
-
- if (is_destroying_effect) {
- old_values.set(source, value);
- } else {
- old_values.set(source, old_value);
- }
-
- source.v = value;
+ old_values.set(source, is_destroying_effect ? value : source.v);
var batch = Batch.ensure();
- batch.capture(source, old_value);
+ batch.capture(source, value);
if (DEV) {
if (tracing_mode_flag || active_effect !== null) {
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js
new file mode 100644
index 0000000000..ff323b60af
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js
@@ -0,0 +1,22 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [x, y, resolve, commit] = target.querySelectorAll('button');
+ const [p] = target.querySelectorAll('p');
+
+ y.click();
+ await tick();
+ resolve.click();
+ await tick();
+ x.click();
+ await tick();
+ assert.htmlEqual(p.innerHTML, '1 0');
+
+ await tick();
+ commit.click();
+ assert.htmlEqual(p.innerHTML, '1 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte
new file mode 100644
index 0000000000..d6995340af
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte
@@ -0,0 +1,21 @@
+
+
+{x} {await delay(y)}
+
+ x += 1}>x
+ f = fork(() => y += 1)}>y (fork)
+ deferred.shift()?.()}>resolve
+ f.commit()}>commit
From 704e3bb5f0ad25e759c97cf7c8f45be79314d4df Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 2 Apr 2026 13:49:23 -0400
Subject: [PATCH 40/92] chore: agent hints for perf investigations (#18047)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This adds some hints in an `AGENTS.md` for how to conduct performance
investigations, along with a utility for comparing profiles from
different branches to identify hotspots. We will likely want to put
other stuff in `AGENTS.md` in future but I figured we could start with
something immediately useful.
It also tweaks the comparison script — rather than nuking results from
existing branches, it keeps them around so that we don't need to re-run
benchmarks (which takes a long time) for branches that haven't changed.
---------
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
---
.../skills/performance-investigation/SKILL.md | 70 ++++++++++++++++
AGENTS.md | 9 ++
benchmarking/compare/profile-diff.mjs | 83 +++++++++++++++++++
3 files changed, 162 insertions(+)
create mode 100644 .agents/skills/performance-investigation/SKILL.md
create mode 100644 AGENTS.md
create mode 100644 benchmarking/compare/profile-diff.mjs
diff --git a/.agents/skills/performance-investigation/SKILL.md b/.agents/skills/performance-investigation/SKILL.md
new file mode 100644
index 0000000000..cbc5d81882
--- /dev/null
+++ b/.agents/skills/performance-investigation/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: performance-investigation
+description: Investigate performance regressions and find opportunities for optimization
+---
+
+## Quick start
+
+1. Start from a branch you want to measure (for example `foo`).
+2. Run:
+
+```sh
+pnpm bench:compare main foo
+```
+
+If you pass one branch, `bench:compare` automatically compares it to `main`.
+
+## Where outputs go
+
+- Summary report: `benchmarking/compare/.results/report.txt`
+- Raw benchmark numbers:
+ - `benchmarking/compare/.results/main.json`
+ - `benchmarking/compare/.results/.json`
+- CPU profiles (per benchmark, per branch):
+ - `benchmarking/compare/.profiles/main/*.cpuprofile`
+ - `benchmarking/compare/.profiles/main/*.md`
+ - `benchmarking/compare/.profiles//*.cpuprofile`
+ - `benchmarking/compare/.profiles//*.md`
+
+The `.md` files are generated summaries of the CPU profile and are usually the fastest way to inspect hotspots.
+
+## Suggested investigation flow
+
+1. Open `benchmarking/compare/.results/report.txt` and identify largest regressions first.
+2. For each high-delta benchmark, compare:
+ - `benchmarking/compare/.profiles/main/.md`
+ - `benchmarking/compare/.profiles//.md`
+3. Look for changes in self/inclusive hotspot share in runtime internals (`runtime.js`, `reactivity/batch.js`, `reactivity/deriveds.js`, `reactivity/sources.js`).
+4. Make one optimization change at a time, then re-run targeted benches before re-running full compare.
+
+## Fast benchmark loops
+
+Run only selected reactivity benchmarks by substring:
+
+```sh
+pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle
+pnpm bench repeated_deps sbench_create_signals mol_owned
+```
+
+## Tests to run after perf changes
+
+Runtime reactivity regressions are most likely in runes runtime tests:
+
+```sh
+pnpm test runtime-runes
+```
+
+## Helpful script
+
+For quick cpuprofile hotspot deltas between two branches:
+
+```sh
+node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo
+```
+
+This prints top function sample-share deltas for the selected benchmark.
+
+## Practical gotchas
+
+- `bench:compare` checks out branches while running. Avoid uncommitted changes (or stash them) so branch switching is safe.
+- Each `bench:compare` run rewrites `benchmarking/compare/.results` and `benchmarking/compare/.profiles`.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..c6cd3ea310
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+# Svelte Coding Agent Guide
+
+This guide is for AI coding agents working in the Svelte monorepo.
+
+**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here.
+
+## Quick Reference
+
+If asked to do a performance investigation, use the `performance-investigation` skill.
diff --git a/benchmarking/compare/profile-diff.mjs b/benchmarking/compare/profile-diff.mjs
new file mode 100644
index 0000000000..c6a9061ab2
--- /dev/null
+++ b/benchmarking/compare/profile-diff.mjs
@@ -0,0 +1,83 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const [benchmark, baseBranch = 'main', candidateBranch] = process.argv.slice(2);
+
+if (!benchmark || !candidateBranch) {
+ console.error(
+ 'Usage: node benchmarking/compare/profile-diff.mjs '
+ );
+ process.exit(1);
+}
+
+const root = path.resolve('benchmarking/compare/.profiles');
+
+function safe(name) {
+ return name.replace(/[^a-z0-9._-]+/gi, '_');
+}
+
+function read_profile(branch, bench) {
+ const file = path.join(root, safe(branch), `${bench}.cpuprofile`);
+ const profile = JSON.parse(fs.readFileSync(file, 'utf8'));
+ const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
+ const samples = Array.isArray(profile.samples) ? profile.samples : [];
+
+ const id_to_node = new Map(nodes.map((node) => [node.id, node]));
+ const self_counts = new Map();
+
+ for (const sample of samples) {
+ if (typeof sample !== 'number') continue;
+ self_counts.set(sample, (self_counts.get(sample) ?? 0) + 1);
+ }
+
+ const total = samples.length || 1;
+ const by_fn = new Map();
+
+ for (const [id, count] of self_counts) {
+ const node = id_to_node.get(id);
+ if (!node || typeof node !== 'object') continue;
+
+ const frame = node.callFrame ?? {};
+ const function_name = frame.functionName || '(anonymous)';
+ const url = frame.url || '';
+ const line = typeof frame.lineNumber === 'number' ? frame.lineNumber + 1 : 0;
+
+ const label = url
+ ? `${function_name} @ ${url.replace(/^.*packages\//, 'packages/')}:${line}`
+ : function_name;
+
+ by_fn.set(label, (by_fn.get(label) ?? 0) + count);
+ }
+
+ return { by_fn, total };
+}
+
+const base = read_profile(baseBranch, benchmark);
+const candidate = read_profile(candidateBranch, benchmark);
+
+const keys = new Set([...base.by_fn.keys(), ...candidate.by_fn.keys()]);
+const rows = [...keys]
+ .map((key) => {
+ const base_pct = ((base.by_fn.get(key) ?? 0) * 100) / base.total;
+ const candidate_pct = ((candidate.by_fn.get(key) ?? 0) * 100) / candidate.total;
+ return {
+ key,
+ delta: candidate_pct - base_pct,
+ base_pct,
+ candidate_pct
+ };
+ })
+ .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
+ .slice(0, 20);
+
+console.log(`Benchmark: ${benchmark}`);
+console.log(`Base: ${baseBranch}`);
+console.log(`Candidate: ${candidateBranch}`);
+console.log('');
+
+for (const row of rows) {
+ const sign = row.delta >= 0 ? '+' : '';
+ console.log(
+ `${sign}${row.delta.toFixed(2).padStart(6)}pp candidate ${row.candidate_pct.toFixed(2).padStart(6)}% base ${row.base_pct.toFixed(2).padStart(6)}% ${row.key}`
+ );
+}
From a9530e5330374fc95c06c2227a3ae7fe686b3033 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 2 Apr 2026 16:07:23 -0400
Subject: [PATCH 41/92] chore: add labels to more internal deriveds (#18050)
tiny QoL thing
---
packages/svelte/src/internal/client/dom/blocks/each.js | 5 +++++
packages/svelte/src/reactivity/date.js | 4 ++++
2 files changed, 9 insertions(+)
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 3acdf80d84..5f8adee1b5 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -42,6 +42,7 @@ import { DEV } from 'esm-env';
import { derived_safe_equal } from '../../reactivity/deriveds.js';
import { current_batch } from '../../reactivity/batch.js';
import * as e from '../../errors.js';
+import { tag } from '../../dev/tracing.js';
// When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
@@ -205,6 +206,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
return is_array(collection) ? collection : collection == null ? [] : array_from(collection);
});
+ if (DEV) {
+ tag(each_array, '{#each ...}');
+ }
+
/** @type {V[]} */
var array;
diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js
index 8e2b5d41ab..f882c05d76 100644
--- a/packages/svelte/src/reactivity/date.js
+++ b/packages/svelte/src/reactivity/date.js
@@ -95,6 +95,10 @@ export class SvelteDate extends Date {
return date_proto[method].apply(this, args);
});
+ if (DEV) {
+ tag(d, `SvelteDate.${method}()`);
+ }
+
this.#deriveds.set(method, d);
set_active_reaction(reaction);
From 206974a5cddce13106a8deb1bb977bb9a034e031 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Fri, 3 Apr 2026 15:30:16 -0400
Subject: [PATCH 42/92] chore: generate markdown tables of CPU profiles
(#18059)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
extracted from #18035 — this should have been part of #18047 but I
missed it off, oops. Will self-merge so it doesn't get in the way of
#18035, and because the `performance-investigation` skill is predicated
on this code existing
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
benchmarking/compare/generate-report.js | 81 ++++++++
benchmarking/compare/index.js | 70 +------
benchmarking/utils.js | 257 +++++++++++++++++++++++-
3 files changed, 338 insertions(+), 70 deletions(-)
create mode 100644 benchmarking/compare/generate-report.js
diff --git a/benchmarking/compare/generate-report.js b/benchmarking/compare/generate-report.js
new file mode 100644
index 0000000000..a61f58909b
--- /dev/null
+++ b/benchmarking/compare/generate-report.js
@@ -0,0 +1,81 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+export function generate_report(outdir) {
+ const result_files = fs
+ .readdirSync(outdir)
+ .filter((file) => file.endsWith('.json'))
+ .sort((a, b) => a.localeCompare(b));
+
+ const branches = result_files.map((file) => file.slice(0, -5));
+ const results = result_files.map((file) =>
+ JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8'))
+ );
+
+ if (results.length === 0) {
+ console.error(`No result files found in ${outdir}`);
+ process.exit(1);
+ }
+
+ const report_file = path.join(outdir, 'report.txt');
+
+ fs.writeFileSync(report_file, '');
+
+ const write = (str) => {
+ fs.appendFileSync(report_file, str + '\n');
+ console.log(str);
+ };
+
+ for (let i = 0; i < branches.length; i += 1) {
+ write(`${char(i)}: ${branches[i]}`);
+ }
+
+ write('');
+
+ for (let i = 0; i < results[0].length; i += 1) {
+ write(`${results[0][i].benchmark}`);
+
+ for (const metric of ['time', 'gc_time']) {
+ const times = results.map((result) => +result[i][metric]);
+ let min = Infinity;
+ let max = -Infinity;
+ let min_index = -1;
+
+ for (let b = 0; b < times.length; b += 1) {
+ const time = times[b];
+
+ if (time < min) {
+ min = time;
+ min_index = b;
+ }
+
+ if (time > max) {
+ max = time;
+ }
+ }
+
+ if (min !== 0) {
+ write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
+ times.forEach((time, b) => {
+ const SIZE = 20;
+ const n = Math.round(SIZE * (time / max));
+
+ write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
+ });
+ }
+ }
+
+ write('');
+ }
+}
+
+function char(i) {
+ return String.fromCharCode(97 + i);
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const outdir = path.resolve(process.argv[1], '../.results');
+
+ generate_report(outdir);
+}
diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js
index 100d98a767..9064ee7da9 100644
--- a/benchmarking/compare/index.js
+++ b/benchmarking/compare/index.js
@@ -3,6 +3,7 @@ import path from 'node:path';
import { execSync, fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { safe } from '../utils.js';
+import { generate_report } from './generate-report.js';
// if (execSync('git status --porcelain').toString().trim()) {
// console.error('Working directory is not clean');
@@ -12,7 +13,6 @@ import { safe } from '../utils.js';
const filename = fileURLToPath(import.meta.url);
const runner = path.resolve(filename, '../runner.js');
const outdir = path.resolve(filename, '../.results');
-const report_file = `${outdir}/report.txt`;
fs.mkdirSync(outdir, { recursive: true });
@@ -85,70 +85,4 @@ if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
}
-const result_files = fs
- .readdirSync(outdir)
- .filter((file) => file.endsWith('.json'))
- .sort((a, b) => a.localeCompare(b));
-
-const branches = result_files.map((file) => file.slice(0, -5));
-const results = result_files.map((file) =>
- JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8'))
-);
-
-if (results.length === 0) {
- console.error(`No result files found in ${outdir}`);
- process.exit(1);
-}
-
-fs.writeFileSync(report_file, '');
-
-const write = (str) => {
- fs.appendFileSync(report_file, str + '\n');
- console.log(str);
-};
-
-for (let i = 0; i < branches.length; i += 1) {
- write(`${char(i)}: ${branches[i]}`);
-}
-
-write('');
-
-for (let i = 0; i < results[0].length; i += 1) {
- write(`${results[0][i].benchmark}`);
-
- for (const metric of ['time', 'gc_time']) {
- const times = results.map((result) => +result[i][metric]);
- let min = Infinity;
- let max = -Infinity;
- let min_index = -1;
-
- for (let b = 0; b < times.length; b += 1) {
- const time = times[b];
-
- if (time < min) {
- min = time;
- min_index = b;
- }
-
- if (time > max) {
- max = time;
- }
- }
-
- if (min !== 0) {
- write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
- times.forEach((time, b) => {
- const SIZE = 20;
- const n = Math.round(SIZE * (time / max));
-
- write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
- });
- }
- }
-
- write('');
-}
-
-function char(i) {
- return String.fromCharCode(97 + i);
-}
+generate_report(outdir);
diff --git a/benchmarking/utils.js b/benchmarking/utils.js
index 4821a167fe..2f4be3c567 100644
--- a/benchmarking/utils.js
+++ b/benchmarking/utils.js
@@ -49,6 +49,253 @@ export function safe(name) {
return name.replace(/[^a-z0-9._-]+/gi, '_');
}
+/**
+ * @param {unknown} value
+ */
+function format_markdown_value(value) {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value)) return value.map((item) => String(item)).join(', ');
+ if (typeof value === 'object') return JSON.stringify(value);
+ return String(value);
+}
+
+/**
+ * @param {string} text
+ */
+function escape_markdown_cell(text) {
+ return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
+}
+
+/**
+ * @param {string} value
+ */
+function normalize_profile_url(value) {
+ if (!value) return '';
+
+ if (value.startsWith('file://')) {
+ try {
+ const pathname = decodeURIComponent(new URL(value).pathname);
+ const relative = path.relative(process.cwd(), pathname);
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
+ return pathname;
+ } catch {
+ return value;
+ }
+ }
+
+ if (path.isAbsolute(value)) {
+ const relative = path.relative(process.cwd(), value);
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
+ }
+
+ return value;
+}
+
+/**
+ * @param {string} function_name
+ */
+function is_special_runtime_node(function_name) {
+ return function_name === '(idle)' || function_name === '(garbage collector)';
+}
+
+/**
+ * @param {string} normalized_url
+ */
+function is_svelte_source_url(normalized_url) {
+ return normalized_url.startsWith('packages/svelte/');
+}
+
+/**
+ * @param {Record} profile
+ */
+function profile_to_markdown(profile) {
+ /** @type {string[]} */
+ const lines = ['# CPU profile'];
+
+ const metadata = Object.entries(profile).filter(
+ ([key]) => key !== 'nodes' && key !== 'samples' && key !== 'timeDeltas'
+ );
+
+ if (metadata.length > 0) {
+ lines.push('', '## Metadata', '| Field | Value |', '| --- | --- |');
+ for (const [key, value] of metadata) {
+ lines.push(
+ `| ${escape_markdown_cell(key)} | ${escape_markdown_cell(format_markdown_value(value))} |`
+ );
+ }
+ }
+
+ const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
+ const samples = Array.isArray(profile.samples) ? profile.samples : [];
+ const timeDeltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
+ /** @type {Set} */
+ const included_node_ids = new Set();
+
+ if (nodes.length > 0) {
+ /** @type {Map>} */
+ const nodes_by_id = new Map();
+
+ /** @type {Map} */
+ const parent_by_id = new Map();
+
+ for (const node of nodes) {
+ if (!node || typeof node !== 'object') continue;
+ if (typeof node.id !== 'number') continue;
+ nodes_by_id.set(node.id, node);
+ const children = Array.isArray(node.children) ? node.children : [];
+ for (const child of children) {
+ if (typeof child === 'number') {
+ parent_by_id.set(child, node.id);
+ }
+ }
+
+ const callFrame =
+ node.callFrame && typeof node.callFrame === 'object'
+ ? /** @type {Record} */ (node.callFrame)
+ : /** @type {Record} */ ({});
+ const functionName =
+ typeof callFrame.functionName === 'string' ? callFrame.functionName : '(anonymous)';
+ const normalizedUrl =
+ typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : '';
+
+ if (is_special_runtime_node(functionName) || is_svelte_source_url(normalizedUrl)) {
+ included_node_ids.add(node.id);
+ }
+ }
+
+ /** @type {Map} */
+ const self_sample_count = new Map();
+ for (const sample of samples) {
+ if (typeof sample !== 'number') continue;
+ if (!included_node_ids.has(sample)) continue;
+ self_sample_count.set(sample, (self_sample_count.get(sample) ?? 0) + 1);
+ }
+
+ /** @type {Map} */
+ const inclusive_sample_count = new Map();
+ /** @type {Set} */
+ const stack = new Set();
+
+ /** @param {number} node_id */
+ const get_inclusive_count = (node_id) => {
+ const cached = inclusive_sample_count.get(node_id);
+ if (cached !== undefined) return cached;
+ if (stack.has(node_id)) return self_sample_count.get(node_id) ?? 0;
+
+ stack.add(node_id);
+ const node = nodes_by_id.get(node_id);
+ const children = node && Array.isArray(node.children) ? node.children : [];
+ let total = self_sample_count.get(node_id) ?? 0;
+
+ for (const child of children) {
+ if (typeof child !== 'number') continue;
+ total += get_inclusive_count(child);
+ }
+
+ stack.delete(node_id);
+ inclusive_sample_count.set(node_id, total);
+ return total;
+ };
+
+ for (const node_id of included_node_ids) {
+ get_inclusive_count(node_id);
+ }
+
+ const total_samples = [...self_sample_count.values()].reduce((sum, count) => sum + count, 0);
+ if (total_samples > 0) {
+ const hotspot_rows = [...included_node_ids]
+ .map((id) => nodes_by_id.get(id))
+ .filter((node) => !!node)
+ .map((node) => {
+ const id = /** @type {number} */ (node.id);
+ const callFrame =
+ node.callFrame && typeof node.callFrame === 'object'
+ ? /** @type {Record} */ (node.callFrame)
+ : /** @type {Record} */ ({});
+ const functionName =
+ typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0
+ ? callFrame.functionName
+ : '(anonymous)';
+ const selfCount = self_sample_count.get(id) ?? 0;
+ const inclusiveCount = inclusive_sample_count.get(id) ?? selfCount;
+ return { id, functionName, selfCount, inclusiveCount };
+ })
+ .filter((row) => row.selfCount > 0 || row.inclusiveCount > 0)
+ .sort(
+ (a, b) =>
+ b.inclusiveCount - a.inclusiveCount ||
+ b.selfCount - a.selfCount ||
+ String(a.id).localeCompare(String(b.id))
+ )
+ .slice(0, 25);
+
+ if (hotspot_rows.length > 0) {
+ lines.push(
+ '',
+ '## Top hotspots',
+ '| Rank | Node ID | Function | Self samples | Self % | Inclusive samples | Inclusive % |',
+ '| --- | --- | --- | --- | --- | --- | --- |'
+ );
+
+ for (let i = 0; i < hotspot_rows.length; i += 1) {
+ const row = hotspot_rows[i];
+ const selfPct = ((row.selfCount / total_samples) * 100).toFixed(2);
+ const inclusivePct = ((row.inclusiveCount / total_samples) * 100).toFixed(2);
+ lines.push(
+ `| ${i + 1} | ${row.id} | ${escape_markdown_cell(row.functionName)} | ${row.selfCount} | ${selfPct}% | ${row.inclusiveCount} | ${inclusivePct}% |`
+ );
+ }
+ }
+ }
+
+ lines.push(
+ '',
+ '## Nodes',
+ '| ID | Parent ID | Function | URL | Line | Column | Hit count | Children | Deopt reason |',
+ '| --- | --- | --- | --- | --- | --- | --- | --- | --- |'
+ );
+
+ for (const node of nodes) {
+ if (!node || typeof node !== 'object') continue;
+ if (typeof node.id !== 'number') continue;
+ if (!included_node_ids.has(node.id)) continue;
+
+ const callFrame =
+ node.callFrame && typeof node.callFrame === 'object'
+ ? /** @type {Record} */ (node.callFrame)
+ : /** @type {Record} */ ({});
+
+ const id = typeof node.id === 'number' ? node.id : '';
+ const parentId =
+ typeof id === 'number' && included_node_ids.has(parent_by_id.get(id) ?? NaN)
+ ? parent_by_id.get(id) ?? ''
+ : '';
+ const functionName =
+ typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0
+ ? callFrame.functionName
+ : '(anonymous)';
+ const url = typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : '';
+ const lineNumber =
+ typeof callFrame.lineNumber === 'number' ? String(callFrame.lineNumber + 1) : '';
+ const columnNumber =
+ typeof callFrame.columnNumber === 'number' ? String(callFrame.columnNumber + 1) : '';
+ const hitCount = typeof node.hitCount === 'number' ? node.hitCount : '';
+ const children = Array.isArray(node.children)
+ ? node.children
+ .filter((child) => typeof child === 'number' && included_node_ids.has(child))
+ .join(', ')
+ : '';
+ const deoptReason = typeof node.deoptReason === 'string' ? node.deoptReason : '';
+
+ lines.push(
+ `| ${escape_markdown_cell(String(id))} | ${escape_markdown_cell(String(parentId))} | ${escape_markdown_cell(functionName)} | ${escape_markdown_cell(url)} | ${escape_markdown_cell(lineNumber)} | ${escape_markdown_cell(columnNumber)} | ${escape_markdown_cell(String(hitCount))} | ${escape_markdown_cell(children)} | ${escape_markdown_cell(deoptReason)} |`
+ );
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
/**
* @template T
* @param {string | null} profile_dir
@@ -73,8 +320,14 @@ export async function with_cpu_profile(profile_dir, profile_name, fn) {
return await fn();
} finally {
const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop'));
- const file = path.join(profile_dir, `${safe(profile_name)}.cpuprofile`);
- fs.writeFileSync(file, JSON.stringify(profile));
+ const safe_profile_name = safe(profile_name);
+ const profile_file = path.join(profile_dir, `${safe_profile_name}.cpuprofile`);
+ const markdown_file = path.join(profile_dir, `${safe_profile_name}.md`);
+ fs.writeFileSync(profile_file, JSON.stringify(profile));
+ fs.writeFileSync(
+ markdown_file,
+ profile_to_markdown(/** @type {Record} */ (profile))
+ );
session.disconnect();
}
}
From 8ee2169609716acb5661d32357df829dd78536a4 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Fri, 3 Apr 2026 16:17:24 -0400
Subject: [PATCH 43/92] chore: improve benchmarks (#18061)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This makes the benchmarks slightly more honest — it adds the async flag,
and a benchmark that tests the (common) scenario where most effects are
clean. Relevant to #18035, will self-merge so we can do a fresh
comparison
---
benchmarking/benchmarks/reactivity/index.js | 1 +
.../reactivity/tests/clean_effects.bench.js | 32 +++++++++++++++++++
2 files changed, 33 insertions(+)
create mode 100644 benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js
diff --git a/benchmarking/benchmarks/reactivity/index.js b/benchmarking/benchmarks/reactivity/index.js
index 2b75b3dfc6..3fe9639376 100644
--- a/benchmarking/benchmarks/reactivity/index.js
+++ b/benchmarking/benchmarks/reactivity/index.js
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
+import 'svelte/internal/flags/async';
import {
sbench_create_0to1,
sbench_create_1000to1,
diff --git a/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js
new file mode 100644
index 0000000000..a617554f1b
--- /dev/null
+++ b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js
@@ -0,0 +1,32 @@
+import assert from 'node:assert';
+import * as $ from 'svelte/internal/client';
+
+export default () => {
+ const a = $.state(1);
+ const b = $.state(2);
+
+ let total = 0;
+
+ const destroy = $.effect_root(() => {
+ for (let i = 0; i < 1000; i += 1) {
+ $.render_effect(() => {
+ total += $.get(a);
+ });
+ }
+
+ $.render_effect(() => {
+ total += $.get(b);
+ });
+ });
+
+ return {
+ destroy,
+ run() {
+ for (let i = 0; i < 5; i++) {
+ total = 0;
+ $.flush(() => $.set(b, i));
+ assert.equal(total, i);
+ }
+ }
+ };
+};
From f8ef6de3251bfd4e7c9482092b9c4dc657bb3425 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Fri, 3 Apr 2026 18:38:08 -0400
Subject: [PATCH 44/92] chore: squelch hydration warnings in tests (#18046)
Gets rid of some annoying console spam. I don't totally understand why
`warnings` isn't populated by the time the assertions happen (it seems
to happen just afterwards, except if I sleep for a few milliseconds and
then do the assertion the warnings get swallowed altogether?) but for
right now I don't much care
---
.../samples/async-if-after-await-in-script/_config.js | 4 +++-
.../runtime-runes/samples/await-html-hydration/_config.js | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js
index 2b8ab6e894..1003d3da05 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js
@@ -6,11 +6,13 @@ export default test({
ssrHtml: 'yep
',
- async test({ assert, target, variant }) {
+ async test({ assert, target, variant, warnings }) {
if (variant === 'dom') {
await tick();
}
assert.htmlEqual(target.innerHTML, 'yep
');
+
+ assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet
}
});
diff --git a/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js
index e7983a3de9..87a697b18f 100644
--- a/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js
@@ -3,5 +3,7 @@ import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['hydrate'],
- async test() {}
+ async test({ assert, warnings }) {
+ assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet
+ }
});
From 7a2a17557cd24c14eb1f7bbfcc6d228bb593da50 Mon Sep 17 00:00:00 2001
From: Matia <38083522+matiadev@users.noreply.github.com>
Date: Mon, 6 Apr 2026 16:24:56 +0200
Subject: [PATCH 45/92] docs: add CSS custom properties section to style
directive (#18065)
Adds documentation for using CSS custom properties with the `style:`
directive. I think this was lost at some point during the great
transition. It's only mentioned in the [best
practices](https://svelte.dev/docs/svelte/best-practices#Using-JavaScript-variables-in-CSS)
section, so I thought it would make sense to include it.
---
documentation/docs/03-template-syntax/17-style.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/documentation/docs/03-template-syntax/17-style.md b/documentation/docs/03-template-syntax/17-style.md
index 8b25c221d6..6ddb128f4a 100644
--- a/documentation/docs/03-template-syntax/17-style.md
+++ b/documentation/docs/03-template-syntax/17-style.md
@@ -42,3 +42,9 @@ even over `!important` properties:
This will be red
This will still be red
```
+
+You can set CSS custom properties:
+
+```svelte
+...
+```
From 14adb8caa9eb44b3b4c38233e00740b01bb65e7e Mon Sep 17 00:00:00 2001
From: ottomated <31470743+ottomated@users.noreply.github.com>
Date: Mon, 6 Apr 2026 07:34:33 -0700
Subject: [PATCH 46/92] fix: correct types for `ontoggle` on elements
(#18063)
`` elements fire `ontoggle` as ToggleEvents
([source](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event)),
but they're currently just typed as Event.
### 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:`.
- [ ] 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/great-toes-behave.md | 5 +++++
packages/svelte/elements.d.ts | 6 +++---
2 files changed, 8 insertions(+), 3 deletions(-)
create mode 100644 .changeset/great-toes-behave.md
diff --git a/.changeset/great-toes-behave.md b/.changeset/great-toes-behave.md
new file mode 100644
index 0000000000..26e36f70f1
--- /dev/null
+++ b/.changeset/great-toes-behave.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correct types for `ontoggle` on elements
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index 885004dd2a..f18b7dea98 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -952,9 +952,9 @@ export interface HTMLDetailsAttributes extends HTMLAttributes | undefined | null;
- ontoggle?: EventHandler | undefined | null;
- ontogglecapture?: EventHandler | undefined | null;
+ 'on:toggle'?: ToggleEventHandler | undefined | null;
+ ontoggle?: ToggleEventHandler | undefined | null;
+ ontogglecapture?: ToggleEventHandler | undefined | null;
}
export interface HTMLDelAttributes extends HTMLAttributes {
From adba758067e0e68f9fbdcd72a6103437ec061a27 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 6 Apr 2026 16:52:29 +0200
Subject: [PATCH 47/92] fix: don't reset status of uninitialized deriveds
(#18054)
If a new branch is created (e.g. if block becomes truthy) inside a fork
and a derived is read inside the new branch for the first time, it will
get reactions but its true value will stay uninitialized due to the
fork. If the fork then commits, it will NOT change its value because it
will not reexecute: there is no effect running after commit telling it
to do that, because it was only read inside new effects which already
ran while still inside the fork.
Now, when that branch is removed (e.g. if block becomes falsy), the
derived loses its reactions again and becomes disconnected, at which
point the status is reset. So we end up with a maybe_dirty or clean
derived and an uninitialized value, causing breakage if it's called
again afterwards.
The fix is to not reset the status in this case.
Fixes https://github.com/sveltejs/kit/issues/15126
Fixes https://github.com/sveltejs/kit/issues/15318
Fixes https://github.com/sveltejs/kit/issues/15061
---
.changeset/silent-rings-yell.md | 5 +++++
.../svelte/src/internal/client/runtime.js | 9 ++++++++-
.../samples/async-fork-if-derived/_config.js | 20 +++++++++++++++++++
.../samples/async-fork-if-derived/main.svelte | 13 ++++++++++++
4 files changed, 46 insertions(+), 1 deletion(-)
create mode 100644 .changeset/silent-rings-yell.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte
diff --git a/.changeset/silent-rings-yell.md b/.changeset/silent-rings-yell.md
new file mode 100644
index 0000000000..0b417103f7
--- /dev/null
+++ b/.changeset/silent-rings-yell.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't reset status of uninitialized deriveds
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 906d68fbf0..d9578142eb 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -399,7 +399,14 @@ function remove_reaction(signal, dependency) {
derived.f &= ~WAS_MARKED;
}
- update_derived_status(derived);
+ // In a fork it's possible that a derived is executed and gets reactions, then commits, but is
+ // never re-executed. This is possible when the derived is only executed once in the context
+ // of a new branch which happens before fork.commit() runs. In this case, the derived still has
+ // UNINITIALIZED as its value, and then when it's loosing its reactions we need to ensure it stays
+ // DIRTY so it is reexecuted once someone wants its value again.
+ if (derived.v !== UNINITIALIZED) {
+ update_derived_status(derived);
+ }
// freeze any effects inside this derived
freeze_derived_effects(derived);
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js
new file mode 100644
index 0000000000..4a03a54e7a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js
@@ -0,0 +1,20 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [, toggle] = target.querySelectorAll('button');
+
+ toggle?.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `0 toggle 0`);
+
+ toggle?.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `0 toggle `);
+
+ toggle?.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `0 toggle 0`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte
new file mode 100644
index 0000000000..519857a9ce
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte
@@ -0,0 +1,13 @@
+
+
+ count += 1}>{count}
+ fork(() => show = !show).commit()}>toggle
+
+{#if show}
+ {d_count}
+{/if}
From d86cb5cc327a29e7e509ada93a3338d2118aab93 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 6 Apr 2026 16:59:37 +0200
Subject: [PATCH 48/92] fix: skip rebase logic in non-async mode (#18040)
Fixes part of #17940 (the hydration->error thing still needs a repro).
Essentially in sync mode render effects are executed during traversing
the effect tree, and when flushSync is called during that it can cause
the timing of things getting out of sync such that you end up wanting to
rebase another branch, which is never needed in sync mode.
So we just skip that logic in sync mode. Another way would be to adjust
flushSync such that it does appends itself to the end of the current
flushing cycle if it notices processing is already ongoing. This will
not work when you pass a callback function to `flushSync` though -
another indicator that we should probably remove that callback argument.
---------
Co-authored-by: Rich Harris
Co-authored-by: Rich Harris
---
.changeset/ripe-mails-wave.md | 5 +++++
.../src/internal/client/reactivity/batch.js | 5 ++++-
.../flush-sync-each-block/Inner.svelte | 8 ++++++++
.../samples/flush-sync-each-block/_config.js | 20 +++++++++++++++++++
.../samples/flush-sync-each-block/main.svelte | 12 +++++++++++
5 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 .changeset/ripe-mails-wave.md
create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte
create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js
create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte
diff --git a/.changeset/ripe-mails-wave.md b/.changeset/ripe-mails-wave.md
new file mode 100644
index 0000000000..6ccc1c724d
--- /dev/null
+++ b/.changeset/ripe-mails-wave.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: skip rebase logic in non-async mode
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 23cc73ffc8..f98436e7bc 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -349,7 +349,9 @@ export class Batch {
next_batch.#process();
}
- if (!batches.has(this)) {
+ // 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();
}
}
@@ -791,6 +793,7 @@ export class Batch {
}
}
+// TODO Svelte@6 think about removing the callback argument.
/**
* Synchronously flush any pending updates.
* Returns void if no callback is provided, otherwise returns the result of calling the callback.
diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte
new file mode 100644
index 0000000000..6d7c982db0
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte
@@ -0,0 +1,8 @@
+
+
+{value}
diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js
new file mode 100644
index 0000000000..2798f533c6
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js
@@ -0,0 +1,20 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ inc
+
+ 2
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte
new file mode 100644
index 0000000000..3be1202c97
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte
@@ -0,0 +1,12 @@
+
+
+ count++}>inc
+
+
+
+{#each [count] as row}
+ {row}
+{/each}
From cd5bda00a81687df415d6b35ad1b16109f847216 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 6 Apr 2026 12:06:41 -0400
Subject: [PATCH 49/92] chore: fix changeset (#18073)
prevents the changelog from getting borked up
---
.changeset/great-toes-behave.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/great-toes-behave.md b/.changeset/great-toes-behave.md
index 26e36f70f1..5f2845e364 100644
--- a/.changeset/great-toes-behave.md
+++ b/.changeset/great-toes-behave.md
@@ -2,4 +2,4 @@
'svelte': patch
---
-fix: correct types for `ontoggle` on elements
+fix: correct types for `ontoggle` on `` elements
From 0395ef0df7ff12c4b633b650c5f2db512d382836 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 6 Apr 2026 18:11:08 +0200
Subject: [PATCH 50/92] fix: unskip branches of earlier batches after commit
(#18048)
Fixes #17571 where the situation is the following:
A derived creates a new query. That query initializes loading with true.
This means the if block is marked for destruction (therefore effects
inside branch are skipped), but it's not doing that yet because the
query promise is pending. Then query resolves and loading is set back to
false right before resolving, but it's not the same tick so
`loading=false` is a separate thing. Because that later batch doesn't
see any overlap with an earlier batch (the earlier batch did set loading
to true but not via set but indirectly via recreating the query) it
doesn't wait on it and flushes right away. Now the if block is marked as
visible again but the earlier batch doesn't know that if noone unskips
its branch. If we don't do that the render effect that is now dirty as
part of that batch will not run.
---
.changeset/petite-signs-flash.md | 5 +++
.../src/internal/client/reactivity/batch.js | 28 +++++++++++--
.../samples/async-if-block-unskip/_config.js | 30 ++++++++++++++
.../samples/async-if-block-unskip/main.svelte | 40 +++++++++++++++++++
4 files changed, 100 insertions(+), 3 deletions(-)
create mode 100644 .changeset/petite-signs-flash.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte
diff --git a/.changeset/petite-signs-flash.md b/.changeset/petite-signs-flash.md
new file mode 100644
index 0000000000..38a1c7b47c
--- /dev/null
+++ b/.changeset/petite-signs-flash.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: unskip branches of earlier batches after commit
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index f98436e7bc..9c0832150a 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -172,6 +172,12 @@ export class Batch {
*/
#skipped_branches = new Map();
+ /**
+ * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing
+ * @type {Set}
+ */
+ #unskipped_branches = new Set();
+
is_fork = false;
#decrement_queued = false;
@@ -215,28 +221,31 @@ export class Batch {
if (!this.#skipped_branches.has(effect)) {
this.#skipped_branches.set(effect, { d: [], m: [] });
}
+ this.#unskipped_branches.delete(effect);
}
/**
* Remove an effect from the #skipped_branches map and reschedule
* any tracked dirty/maybe_dirty child effects
* @param {Effect} effect
+ * @param {(e: Effect) => void} callback
*/
- unskip_effect(effect) {
+ unskip_effect(effect, callback = (e) => this.schedule(e)) {
var tracked = this.#skipped_branches.get(effect);
if (tracked) {
this.#skipped_branches.delete(effect);
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
- this.schedule(e);
+ callback(e);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
- this.schedule(e);
+ callback(e);
}
}
+ this.#unskipped_branches.add(effect);
}
#process() {
@@ -532,6 +541,19 @@ export class Batch {
invariant(batch.#roots.length === 0, 'Batch has scheduled roots');
}
+ // A batch was unskipped in a later batch -> tell prior batches to unskip it, too
+ if (is_earlier) {
+ for (const unskipped of this.#unskipped_branches) {
+ batch.unskip_effect(unskipped, (e) => {
+ if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) {
+ batch.schedule(e);
+ } else {
+ batch.#defer_effects([e]);
+ }
+ });
+ }
+ }
+
batch.activate();
/** @type {Set} */
diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js
new file mode 100644
index 0000000000..d578def483
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js
@@ -0,0 +1,30 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [load, resolve] = target.querySelectorAll('button');
+
+ load.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ load
+ resolve
+ `
+ );
+
+ resolve.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ search search search search
+ load
+ resolve
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte
new file mode 100644
index 0000000000..36ebcd26d1
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte
@@ -0,0 +1,40 @@
+
+
+{query} {await promise}
+
+{#if !promise.loading}
+ {query}
+{/if}
+
+{#if !promise.loading}
+ {await query}
+{/if}
+
+ query = 'search'}>load
+ resolvers.shift()?.()}>resolve
From 8966601dcd14582cd46d4fbb7c5cf1b444292255 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 6 Apr 2026 17:42:32 -0400
Subject: [PATCH 51/92] fix: handle parens in template expressions more
robustly (#18075)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
While looking into #17954 I realised that a) our code for handling
parentheses in expressions is unnecessarily convoluted and b) it doesn't
handle the case where you have an opening parent outside the first
comment — this fails to parse:
```svelte
{(/**/ 42)}
```
This fixes it and simplifies the code a good bit.
### 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/dull-cows-tie.md | 5 ++
.../src/compiler/phases/1-parse/acorn.js | 31 ++++++----
.../compiler/phases/1-parse/read/context.js | 20 +++---
.../phases/1-parse/read/expression.js | 40 +-----------
.../src/compiler/phases/1-parse/state/tag.js | 7 +--
.../parser-modern/samples/parens/input.svelte | 1 +
.../parser-modern/samples/parens/output.json | 61 +++++++++++++++++++
7 files changed, 100 insertions(+), 65 deletions(-)
create mode 100644 .changeset/dull-cows-tie.md
create mode 100644 packages/svelte/tests/parser-modern/samples/parens/input.svelte
create mode 100644 packages/svelte/tests/parser-modern/samples/parens/output.json
diff --git a/.changeset/dull-cows-tie.md b/.changeset/dull-cows-tie.md
new file mode 100644
index 0000000000..9835805dce
--- /dev/null
+++ b/.changeset/dull-cows-tie.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: handle parens in template expressions more robustly
diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js
index 77ce4a461c..797ab4cea5 100644
--- a/packages/svelte/src/compiler/phases/1-parse/acorn.js
+++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js
@@ -1,5 +1,6 @@
/** @import { Comment, Program } from 'estree' */
/** @import { AST } from '#compiler' */
+/** @import { Parser } from './index.js' */
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
@@ -66,26 +67,22 @@ export function parse(source, comments, typescript, is_script) {
}
/**
+ * @param {Parser} parser
* @param {string} source
- * @param {Comment[]} comments
- * @param {boolean} typescript
* @param {number} index
* @returns {acorn.Expression & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }}
*/
-export function parse_expression_at(source, comments, typescript, index) {
- const parser = typescript ? ParserWithTS : acorn.Parser;
+export function parse_expression_at(parser, source, index) {
+ const _ = parser.ts ? ParserWithTS : acorn.Parser;
- const { onComment, add_comments } = get_comment_handlers(
- source,
- /** @type {CommentWithLocation[]} */ (comments),
- index
- );
+ const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index);
- const ast = parser.parseExpressionAt(source, index, {
+ const ast = _.parseExpressionAt(source, index, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
- locations: true
+ locations: true,
+ preserveParens: true
});
add_comments(ast);
@@ -93,6 +90,18 @@ export function parse_expression_at(source, comments, typescript, index) {
return ast;
}
+/**
+ * @param {acorn.Expression} node
+ * @returns {acorn.Expression}
+ */
+export function remove_parens(node) {
+ return walk(node, null, {
+ ParenthesizedExpression(node, context) {
+ return context.visit(node.expression);
+ }
+ });
+}
+
/**
* Acorn doesn't add comments to the AST by itself. This factory returns the capabilities
* to add them after the fact. They are needed in order to support `svelte-ignore` comments
diff --git a/packages/svelte/src/compiler/phases/1-parse/read/context.js b/packages/svelte/src/compiler/phases/1-parse/read/context.js
index f90d59fa0b..24b7e2c6b0 100644
--- a/packages/svelte/src/compiler/phases/1-parse/read/context.js
+++ b/packages/svelte/src/compiler/phases/1-parse/read/context.js
@@ -1,7 +1,7 @@
/** @import { Pattern } from 'estree' */
/** @import { Parser } from '../index.js' */
import { match_bracket } from '../utils/bracket.js';
-import { parse_expression_at } from '../acorn.js';
+import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js';
@@ -49,14 +49,12 @@ export default function read_pattern(parser) {
space_with_newline =
space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1);
- const expression = /** @type {any} */ (
- parse_expression_at(
- `${space_with_newline}(${pattern_string} = 1)`,
- parser.root.comments,
- parser.ts,
- start - 1
- )
- ).left;
+ /** @type {any} */
+ let expression = remove_parens(
+ parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1)
+ );
+
+ expression = expression.left;
expression.typeAnnotation = read_type_annotation(parser);
if (expression.typeAnnotation) {
@@ -92,13 +90,13 @@ function read_type_annotation(parser) {
// parameters as part of a sequence expression instead, and will then error on optional
// parameters (`?:`). Therefore replace that sequence with something that will not error.
parser.template.slice(parser.index).replace(/\?\s*:/g, ':');
- let expression = parse_expression_at(template, parser.root.comments, parser.ts, a);
+ let expression = remove_parens(parse_expression_at(parser, template, a));
// `foo: bar = baz` gets mangled — fix it
if (expression.type === 'AssignmentExpression') {
let b = expression.right.start;
while (template[b] !== '=') b -= 1;
- expression = parse_expression_at(template.slice(0, b), parser.root.comments, parser.ts, a);
+ expression = remove_parens(parse_expression_at(parser, template.slice(0, b), a));
}
// `array as item: string, index` becomes `string, index`, which is mistaken as a sequence expression - fix that
diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js
index 5d21f85792..16d4c4e50f 100644
--- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js
+++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js
@@ -1,6 +1,6 @@
/** @import { Expression } from 'estree' */
/** @import { Parser } from '../index.js' */
-import { parse_expression_at } from '../acorn.js';
+import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js';
import { find_matching_bracket } from '../utils/bracket.js';
@@ -34,50 +34,16 @@ export function get_loose_identifier(parser, opening_token) {
*/
export default function read_expression(parser, opening_token, disallow_loose) {
try {
- let comment_index = parser.root.comments.length;
-
- const node = parse_expression_at(
- parser.template,
- parser.root.comments,
- parser.ts,
- parser.index
- );
-
- let num_parens = 0;
-
- let i = parser.root.comments.length;
- while (i-- > comment_index) {
- const comment = parser.root.comments[i];
- if (comment.end < node.start) {
- parser.index = comment.end;
- break;
- }
- }
-
- for (let i = parser.index; i < /** @type {number} */ (node.start); i += 1) {
- if (parser.template[i] === '(') num_parens += 1;
- }
+ const node = parse_expression_at(parser, parser.template, parser.index);
let index = /** @type {number} */ (node.end);
const last_comment = parser.root.comments.at(-1);
if (last_comment && last_comment.end > index) index = last_comment.end;
- while (num_parens > 0) {
- const char = parser.template[index];
-
- if (char === ')') {
- num_parens -= 1;
- } else if (!regex_whitespace.test(char)) {
- e.expected_token(index, ')');
- }
-
- index += 1;
- }
-
parser.index = index;
- return /** @type {Expression} */ (node);
+ return /** @type {Expression} */ (remove_parens(node));
} catch (err) {
// If we are in an each loop we need the error to be thrown in cases like
// `as { y = z }` so we still throw and handle the error there
diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js
index d9518c726f..ff153128a5 100644
--- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js
+++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js
@@ -392,12 +392,7 @@ function open(parser) {
let function_expression = matched
? /** @type {ArrowFunctionExpression} */ (
- parse_expression_at(
- prelude + `${params} => {}`,
- parser.root.comments,
- parser.ts,
- params_start
- )
+ parse_expression_at(parser, prelude + `${params} => {}`, params_start)
)
: { params: [] };
diff --git a/packages/svelte/tests/parser-modern/samples/parens/input.svelte b/packages/svelte/tests/parser-modern/samples/parens/input.svelte
new file mode 100644
index 0000000000..ad4f4b7940
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/parens/input.svelte
@@ -0,0 +1 @@
+{(/**/ 42)}
diff --git a/packages/svelte/tests/parser-modern/samples/parens/output.json b/packages/svelte/tests/parser-modern/samples/parens/output.json
new file mode 100644
index 0000000000..7a5b4b38d8
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/parens/output.json
@@ -0,0 +1,61 @@
+{
+ "css": null,
+ "js": [],
+ "start": 0,
+ "end": 11,
+ "type": "Root",
+ "fragment": {
+ "type": "Fragment",
+ "nodes": [
+ {
+ "type": "ExpressionTag",
+ "start": 0,
+ "end": 11,
+ "expression": {
+ "type": "Literal",
+ "start": 7,
+ "end": 9,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 7
+ },
+ "end": {
+ "line": 1,
+ "column": 9
+ }
+ },
+ "value": 42,
+ "raw": "42",
+ "leadingComments": [
+ {
+ "type": "Block",
+ "value": "",
+ "start": 2,
+ "end": 6
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "options": null,
+ "comments": [
+ {
+ "type": "Block",
+ "value": "",
+ "start": 2,
+ "end": 6,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 2
+ },
+ "end": {
+ "line": 1,
+ "column": 6
+ }
+ }
+ }
+ ]
+}
From 6b653b8d17c80b16659c5238875977f0941490c2 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 7 Apr 2026 15:06:21 -0400
Subject: [PATCH 52/92] chore: simplify parser (#18077)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
small tidy-up spurred by #17954 — rather than try-catching every
`parse_expression_at` call and passing the error to
`parser.acorn_error`, we can handle the error locally and get rid of
that method
### 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.
- [ ] 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: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
---
.../src/compiler/phases/1-parse/acorn.js | 66 ++++++++++++-------
.../src/compiler/phases/1-parse/index.js | 10 ---
.../compiler/phases/1-parse/read/context.js | 54 +++++++--------
.../phases/1-parse/read/expression.js | 2 +-
.../compiler/phases/1-parse/read/script.js | 9 +--
5 files changed, 69 insertions(+), 72 deletions(-)
diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js
index 797ab4cea5..45a7c2a58c 100644
--- a/packages/svelte/src/compiler/phases/1-parse/acorn.js
+++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js
@@ -4,8 +4,10 @@
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
+import * as e from '../../errors.js';
-const ParserWithTS = acorn.Parser.extend(tsPlugin());
+const JSParser = acorn.Parser;
+const TSParser = JSParser.extend(tsPlugin());
/**
* @typedef {Comment & {
@@ -21,15 +23,15 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin());
* @param {boolean} [is_script]
*/
export function parse(source, comments, typescript, is_script) {
- const parser = typescript ? ParserWithTS : acorn.Parser;
+ const acorn = typescript ? TSParser : JSParser;
const { onComment, add_comments } = get_comment_handlers(
source,
/** @type {CommentWithLocation[]} */ (comments)
);
- // @ts-ignore
- const parse_statement = parser.prototype.parseStatement;
+ // @ts-expect-error
+ const parse_statement = acorn.prototype.parseStatement;
// If we're dealing with a
+
+ f = fork(() => show = true)}>show
+ f.commit()}>commit
+ f.discard()}>discard
+
+
+ {#if show}
+ {await Promise.reject('boom')}
+ {/if}
+ {#snippet failed()}
+ failed
+ {/snippet}
+
\ No newline at end of file
From 7be1a0247f1ea84db2e951ab27716c68de5b0650 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 8 Apr 2026 22:49:04 +0200
Subject: [PATCH 58/92] fix: ensure proper HMR updates for dynamic components
(#18079)
The BranchManager in `branches.js` does create a temporary anchor when
creating a new branch offscreen, and deletes it once the branch is
committed. Normally this is fine, but the combination of HMR and dynamic
components leads to a bug: Since `svelte-component.js` passes the
temporary anchor along to the component it generates, which is the HMR
wrapper, this wrapper will have an obsolete, disconnected anchor on
updates, leading to the content disappearing.
The fix is to add a dev-only symbol which we set on the original (then
obsolete) anchor to tell about the updated anchor that should be used
for HMR updates.
Fixes https://github.com/sveltejs/kit/issues/14699 Fixes #17211
---
.changeset/all-masks-dig.md | 5 ++++
.../svelte/src/internal/client/constants.js | 2 ++
.../svelte/src/internal/client/dev/hmr.js | 9 ++++--
.../internal/client/dom/blocks/branches.js | 8 +++++
.../hmr-dynamic-component/Component.svelte | 1 +
.../samples/hmr-dynamic-component/_config.js | 30 +++++++++++++++++++
.../samples/hmr-dynamic-component/main.svelte | 9 ++++++
7 files changed, 61 insertions(+), 3 deletions(-)
create mode 100644 .changeset/all-masks-dig.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
diff --git a/.changeset/all-masks-dig.md b/.changeset/all-masks-dig.md
new file mode 100644
index 0000000000..ffa40655b9
--- /dev/null
+++ b/.changeset/all-masks-dig.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure proper HMR updates for dynamic components
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index df96f4899b..a3ad988ba1 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -62,6 +62,8 @@ export const STATE_SYMBOL = Symbol('$state');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');
+/** An anchor might change, via this symbol on the original anchor we can tell HMR about the updated anchor */
+export const HMR_ANCHOR = Symbol('hmr anchor');
/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */
export const STALE_REACTION = new (class StaleReactionError extends Error {
diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js
index 13ee35b20d..73dba95f9b 100644
--- a/packages/svelte/src/internal/client/dev/hmr.js
+++ b/packages/svelte/src/internal/client/dev/hmr.js
@@ -1,6 +1,6 @@
/** @import { Effect, TemplateNode } from '#client' */
import { FILENAME, HMR } from '../../../constants.js';
-import { EFFECT_TRANSPARENT } from '#client/constants';
+import { EFFECT_TRANSPARENT, HMR_ANCHOR } from '#client/constants';
import { hydrate_node, hydrating } from '../dom/hydration.js';
import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set, source } from '../reactivity/sources.js';
@@ -15,10 +15,10 @@ export function hmr(fn) {
const current = source(fn);
/**
- * @param {TemplateNode} anchor
+ * @param {TemplateNode} initial_anchor
* @param {any} props
*/
- function wrapper(anchor, props) {
+ function wrapper(initial_anchor, props) {
let component = {};
let instance = {};
@@ -26,6 +26,7 @@ export function hmr(fn) {
let effect;
let ran = false;
+ let anchor = initial_anchor;
block(() => {
if (component === (component = get(current))) {
@@ -39,6 +40,8 @@ export function hmr(fn) {
}
effect = branch(() => {
+ anchor = /** @type {any} */ (anchor)[HMR_ANCHOR] ?? anchor;
+
// when the component is invalidated, replace it without transitions
if (ran) set_should_intro(false);
diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js
index a8096e0a58..33c34f58bb 100644
--- a/packages/svelte/src/internal/client/dom/blocks/branches.js
+++ b/packages/svelte/src/internal/client/dom/blocks/branches.js
@@ -7,8 +7,10 @@ import {
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
+import { HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
+import { DEV } from 'esm-env';
/**
* @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@@ -91,6 +93,12 @@ export class BranchManager {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
+ if (DEV) {
+ // Tell hmr.js about the anchor it should use for updates,
+ // since the initial one will be removed
+ /** @type {any} */ (offscreen.fragment.lastChild)[HMR_ANCHOR] = this.anchor;
+ }
+
// remove the anchor...
/** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove();
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
new file mode 100644
index 0000000000..491b4e06ed
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
@@ -0,0 +1 @@
+component
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
new file mode 100644
index 0000000000..8adf57f2eb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
@@ -0,0 +1,30 @@
+import { flushSync } from 'svelte';
+import { HMR } from 'svelte/internal/client';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true,
+ hmr: true
+ },
+
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ flushSync();
+ assert.htmlEqual(target.innerHTML, `show component`);
+
+ // Simulate HMR swap on the child component.
+ const hidden = './_output/client/Component' + '.svelte.js';
+ const mod = await import(/* vite-ignore */ hidden);
+ const hmr_data = mod.default[HMR];
+ const fake_incoming = {
+ // Fake a new component, else HMR source's equality check will ignore the update
+ [HMR]: { fn: /** @param {any} args */ (...args) => hmr_data.fn(...args), current: null }
+ };
+ hmr_data.update(fake_incoming);
+ flushSync();
+ assert.htmlEqual(target.innerHTML, `show component`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
new file mode 100644
index 0000000000..7cb6982953
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
@@ -0,0 +1,9 @@
+
+
+ C = Component}>show
+
+
From 3937ec03bdaae8d9c097f4f015560ee4b9e32cf3 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Thu, 9 Apr 2026 20:21:47 +0200
Subject: [PATCH 59/92] fix: correctly calculate `@const` blockers (#18039)
Move the calculation of blockers into the analysis phase and then only
push the right thunks in the transform phase - similar to how we already
do it with top level `$.run`
Fixes #18024
The solution is a tiny bit brittle (not much more than the top level one
we already have) and I tried to make it a bit more robust but ended up
in a rabbit hole in #18032 - we can revisit that solution once all the
old stuff is gone. Until then this is the most pragmatic/non-invasive
change.
---------
Co-authored-by: Rich Harris
---
.changeset/common-flowers-listen.md | 5 ++
.../src/compiler/phases/2-analyze/types.d.ts | 8 +++
.../phases/2-analyze/visitors/ConstTag.js | 25 +++++++++
.../phases/2-analyze/visitors/Fragment.js | 2 +-
.../3-transform/client/visitors/ConstTag.js | 51 +++++++------------
.../3-transform/server/visitors/ConstTag.js | 25 ++++-----
.../svelte/src/compiler/types/template.d.ts | 2 +
.../async-derived-const-blocker/_config.js | 9 ++++
.../async-derived-const-blocker/main.svelte | 16 ++++++
.../_expected/server/index.svelte.js | 11 +---
.../_expected/server/index.svelte.js | 28 ++++------
11 files changed, 107 insertions(+), 75 deletions(-)
create mode 100644 .changeset/common-flowers-listen.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte
diff --git a/.changeset/common-flowers-listen.md b/.changeset/common-flowers-listen.md
new file mode 100644
index 0000000000..8d021b8054
--- /dev/null
+++ b/.changeset/common-flowers-listen.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correctly calculate `@const` blockers
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 9d24f9dbac..7941113a7f 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
+++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
@@ -2,6 +2,7 @@ import type { Scope } from '../scope.js';
import type { ComponentAnalysis, ReactiveStatement } from '../types.js';
import type { AST, StateField, ValidatedCompileOptions } from '#compiler';
import type { ExpressionMetadata } from '../nodes.js';
+import type { Identifier } from 'estree';
export interface AnalysisState {
scope: Scope;
@@ -33,6 +34,13 @@ export interface AnalysisState {
* Set when we're inside a `$derived(...)` expression (but not `$derived.by(...)`) or `@const`
*/
derived_function_depth: number;
+
+ /** Collected info about async `{@const }` declarations */
+ async_consts?: {
+ id: Identifier;
+ /** How many `@const` declarations there are (already) in this scope */
+ declaration_count: number;
+ };
}
export type Context = import('zimmerframe').Context<
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 77ea654905..22ffc24c1e 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
@@ -1,6 +1,7 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
+import * as b from '#compiler/builders';
import { validate_opening_tag } from './shared/utils.js';
/**
@@ -42,4 +43,28 @@ export function ConstTag(node, context) {
function_depth: context.state.function_depth + 1,
derived_function_depth: context.state.function_depth + 1
});
+
+ const has_await = node.metadata.expression.has_await;
+ const blockers = [...node.metadata.expression.dependencies]
+ .map((dep) => dep.blocker)
+ .filter((b) => b !== null && b.object !== context.state.async_consts?.id);
+
+ if (has_await || context.state.async_consts || blockers.length > 0) {
+ const run = (context.state.async_consts ??= {
+ id: context.state.analysis.root.unique('promises'),
+ declaration_count: 0
+ });
+ node.metadata.promises_id = run.id;
+
+ const bindings = context.state.scope.get_bindings(declaration);
+
+ // keep the counter in sync with the number of thunks pushed in ConstTag in transform
+ // TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
+ // via something like the approach in https://github.com/sveltejs/svelte/pull/18032
+ const length = run.declaration_count++;
+ const blocker = b.member(run.id, b.literal(length), true);
+ for (const binding of bindings) {
+ binding.blocker = blocker;
+ }
+ }
}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js
index 02d780dc0d..def3860175 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js
@@ -6,5 +6,5 @@
* @param {Context} context
*/
export function Fragment(node, context) {
- context.next({ ...context.state, fragment: node });
+ context.next({ ...context.state, fragment: node, async_consts: undefined });
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
index d2bd3c10dc..ac8f120d3d 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
@@ -1,7 +1,6 @@
-/** @import { Expression, Identifier, Pattern } from 'estree' */
+/** @import { Expression, Identifier, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
-/** @import { ExpressionMetadata } from '../../../nodes.js' */
import { dev } from '../../../../state.js';
import { extract_identifiers } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
@@ -27,13 +26,7 @@ export function ConstTag(node, context) {
context.state.transform[declaration.id.name] = { read: get_value };
- add_const_declaration(
- context.state,
- declaration.id,
- expression,
- node.metadata.expression,
- context.state.scope.get_bindings(declaration)
- );
+ add_const_declaration(context.state, declaration.id, expression, node.metadata);
} else {
const identifiers = extract_identifiers(declaration.id);
const tmp = b.id(context.state.scope.generate('computed_const'));
@@ -70,13 +63,7 @@ export function ConstTag(node, context) {
expression = b.call('$.tag', expression, b.literal('[@const]'));
}
- add_const_declaration(
- context.state,
- tmp,
- expression,
- node.metadata.expression,
- context.state.scope.get_bindings(declaration)
- );
+ add_const_declaration(context.state, tmp, expression, node.metadata);
for (const node of identifiers) {
context.state.transform[node.name] = {
@@ -90,42 +77,40 @@ export function ConstTag(node, context) {
* @param {ComponentContext['state']} state
* @param {Identifier} id
* @param {Expression} expression
- * @param {ExpressionMetadata} metadata
- * @param {import('#compiler').Binding[]} bindings
+ * @param {AST.ConstTag['metadata']} metadata
*/
-function add_const_declaration(state, id, expression, metadata, bindings) {
+function add_const_declaration(state, id, expression, metadata) {
// we need to eagerly evaluate the expression in order to hit any
// 'Cannot access x before initialization' errors
const after = dev ? [b.stmt(b.call('$.get', id))] : [];
- const has_await = metadata.has_await;
- const blockers = [...metadata.dependencies]
+ const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== state.async_consts?.id);
- if (has_await || state.async_consts || blockers.length > 0) {
+ if (metadata.promises_id) {
const run = (state.async_consts ??= {
- id: b.id(state.scope.generate('promises')),
+ id: metadata.promises_id,
thunks: []
});
state.consts.push(b.let(id));
- const assignment = b.assignment('=', id, expression);
- const body = after.length === 0 ? assignment : b.block([b.stmt(assignment), ...after]);
+ /** @type {Statement | undefined} */
+ let promise_stmt;
if (blockers.length === 1) {
- run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
+ promise_stmt = b.stmt(b.await(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
- run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
+ promise_stmt = b.stmt(b.await(b.call('$.wait', b.array(blockers))));
}
- run.thunks.push(b.thunk(body, has_await));
-
- const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true);
-
- for (const binding of bindings) {
- binding.blocker = blocker;
+ // keep the number of thunks pushed in sync with ConstTag in analysis phase
+ const assignment = b.assignment('=', id, expression);
+ if (promise_stmt) {
+ run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true));
+ } else {
+ run.thunks.push(b.thunk(assignment, metadata.expression.has_await));
}
} else {
state.consts.push(b.const(id, expression));
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
index d2ff9a10b4..87d60442f6 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
@@ -1,4 +1,4 @@
-/** @import { Expression, Pattern } from 'estree' */
+/** @import { Expression, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
@@ -12,36 +12,37 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0];
const id = /** @type {Pattern} */ (context.visit(declaration.id));
const init = /** @type {Expression} */ (context.visit(declaration.init));
- const has_await = node.metadata.expression.has_await;
const blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
- if (has_await || context.state.async_consts || blockers.length > 0) {
+ if (node.metadata.promises_id) {
const run = (context.state.async_consts ??= {
- id: b.id(context.state.scope.generate('promises')),
+ id: node.metadata.promises_id,
thunks: []
});
const identifiers = extract_identifiers(declaration.id);
- const bindings = context.state.scope.get_bindings(declaration);
for (const identifier of identifiers) {
context.state.init.push(b.let(identifier.name));
}
+ /** @type {Statement | undefined} */
+ let promise_stmt;
+
if (blockers.length === 1) {
- run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
+ promise_stmt = b.stmt(b.await(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
- run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
+ promise_stmt = b.stmt(b.await(b.call('Promise.all', b.array(blockers))));
}
+ // keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, init);
- run.thunks.push(b.thunk(b.block([b.stmt(assignment)]), has_await));
-
- const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true);
- for (const binding of bindings) {
- binding.blocker = blocker;
+ if (promise_stmt) {
+ run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true));
+ } else {
+ run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await));
}
} else {
context.state.init.push(b.const(id, init));
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index 3c1e3e772c..2964e46761 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -155,6 +155,8 @@ export namespace AST {
/** @internal */
metadata: {
expression: ExpressionMetadata;
+ /** If this const tag contains an await expression, or needs to wait on other async, this is set */
+ promises_id?: Identifier;
};
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js
new file mode 100644
index 0000000000..afa737f36c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js
@@ -0,0 +1,9 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ assert.htmlEqual(target.innerHTML, 'data
');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte
new file mode 100644
index 0000000000..a154859683
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte
@@ -0,0 +1,16 @@
+
+
+{#if d}
+ {@const {data, hasData} = d}
+
+ {#if hasData}
+ {data}
+ {:else if showFetchCta}
+ Fetch now
+ {:else}
+ No data
+ {/if}
+{/if}
diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
index 5f3cfa6ca6..c0cb999e85 100644
--- a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
@@ -7,16 +7,7 @@ export default function Async_const($$renderer) {
let a;
let b;
-
- var promises = $$renderer.run([
- async () => {
- a = (await $.save(1))();
- },
-
- () => {
- b = a + 1;
- }
- ]);
+ var promises = $$renderer.run([async () => a = (await $.save(1))(), () => b = a + 1]);
$$renderer.push(``);
$$renderer.async([promises[1]], ($$renderer) => $$renderer.push(() => $.escape(b)));
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
index 3a53475944..0cebc412ef 100644
--- 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
@@ -28,25 +28,15 @@ export default function Async_in_derived($$renderer, $$props) {
let no2;
var promises = $$renderer.run([
- async () => {
- yes1 = (await $.save(1))();
- },
-
- async () => {
- yes2 = foo((await $.save(1))());
- },
-
- () => {
- no1 = (async () => {
- return await 1;
- })();
- },
-
- () => {
- no2 = (async () => {
- return await 1;
- })();
- }
+ async () => yes1 = (await $.save(1))(),
+ async () => yes2 = foo((await $.save(1))()),
+ () => no1 = (async () => {
+ return await 1;
+ })(),
+
+ () => no2 = (async () => {
+ return await 1;
+ })()
]);
} else {
$$renderer.push('');
From 0e9e76f29262b5f64ac7a5d4db37ec83c9181634 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 9 Apr 2026 16:45:07 -0400
Subject: [PATCH 60/92] fix: freeze deriveds once their containing effects are
destroyed (#17921)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per
https://github.com/sveltejs/svelte/pull/17862#issuecomment-4049752548,
this freezes the value of a derived if it was created inside a parent
effect that is now destroyed. This prevents the sort of bug where a
derived reads `foo.bar` even though `foo` is now `undefined`.
If the derived is dirty, a warning will be printed.
This PR also gets rid of some weirdness around `derived.parent` — it can
only ever be an `Effect | null`, and there's no need for
`get_derived_parent_effect`.
Blocked on https://github.com/sveltejs/kit/pull/15533 and a follow-up
that switches remote functions to use `$effect.root` (since this
effectively undoes #17171), hence draft.
### 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: github-actions[bot]
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
---
.changeset/silly-phones-follow.md | 5 +++
.../.generated/client-warnings.md | 8 +++++
.../messages/client-warnings/warnings.md | 6 ++++
.../internal/client/reactivity/deriveds.js | 35 ++++++-------------
.../src/internal/client/reactivity/types.d.ts | 4 +--
.../svelte/src/internal/client/warnings.js | 11 ++++++
packages/svelte/tests/signals/test.ts | 18 +++++++---
7 files changed, 57 insertions(+), 30 deletions(-)
create mode 100644 .changeset/silly-phones-follow.md
diff --git a/.changeset/silly-phones-follow.md b/.changeset/silly-phones-follow.md
new file mode 100644
index 0000000000..c8671c6abe
--- /dev/null
+++ b/.changeset/silly-phones-follow.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: freeze deriveds once their containing effects are destroyed
diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md
index 7daf808d61..73c97c253c 100644
--- a/documentation/docs/98-reference/.generated/client-warnings.md
+++ b/documentation/docs/98-reference/.generated/client-warnings.md
@@ -134,6 +134,14 @@ When logging a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/R
The easiest way to log a value as it changes over time is to use the [`$inspect`](/docs/svelte/$inspect) rune. Alternatively, to log things on a one-off basis (for example, inside an event handler) you can use [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) to take a snapshot of the current value.
+### derived_inert
+
+```
+Reading a derived belonging to a now-destroyed effect may result in stale values
+```
+
+A `$derived` value created inside an effect will stop updating when the effect is destroyed. You should create the `$derived` outside the effect, or inside an `$effect.root`.
+
### event_handler_invalid
```
diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md
index 6998173a99..58d00f3933 100644
--- a/packages/svelte/messages/client-warnings/warnings.md
+++ b/packages/svelte/messages/client-warnings/warnings.md
@@ -120,6 +120,12 @@ When logging a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/R
The easiest way to log a value as it changes over time is to use the [`$inspect`](/docs/svelte/$inspect) rune. Alternatively, to log things on a one-off basis (for example, inside an event handler) you can use [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) to take a snapshot of the current value.
+## derived_inert
+
+> Reading a derived belonging to a now-destroyed effect may result in stale values
+
+A `$derived` value created inside an effect will stop updating when the effect is destroyed. You should create the `$derived` outside the effect, or inside an `$effect.root`.
+
## event_handler_invalid
> %handler% should be a function. Did you mean to %suggestion%?
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 77acb23516..12dd7bf672 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -12,7 +12,8 @@ import {
WAS_MARKED,
DESTROYED,
CLEAN,
- REACTION_RAN
+ REACTION_RAN,
+ INERT
} from '#client/constants';
import {
active_reaction,
@@ -67,10 +68,6 @@ export const recent_async_deriveds = new Set();
/*#__NO_SIDE_EFFECTS__*/
export function derived(fn) {
var flags = DERIVED | DIRTY;
- var parent_derived =
- active_reaction !== null && (active_reaction.f & DERIVED) !== 0
- ? /** @type {Derived} */ (active_reaction)
- : null;
if (active_effect !== null) {
// Since deriveds are evaluated lazily, any effects created inside them are
@@ -90,7 +87,7 @@ export function derived(fn) {
rv: 0,
v: /** @type {V} */ (UNINITIALIZED),
wv: 0,
- parent: parent_derived ?? active_effect,
+ parent: active_effect,
ac: null
};
@@ -320,23 +317,6 @@ export function destroy_derived_effects(derived) {
*/
let stack = [];
-/**
- * @param {Derived} derived
- * @returns {Effect | null}
- */
-function get_derived_parent_effect(derived) {
- var parent = derived.parent;
- while (parent !== null) {
- if ((parent.f & DERIVED) === 0) {
- // The original parent effect might've been destroyed but the derived
- // is used elsewhere now - do not return the destroyed effect in that case
- return (parent.f & DESTROYED) === 0 ? /** @type {Effect} */ (parent) : null;
- }
- parent = parent.parent;
- }
- return null;
-}
-
/**
* @template T
* @param {Derived} derived
@@ -345,8 +325,15 @@ function get_derived_parent_effect(derived) {
export function execute_derived(derived) {
var value;
var prev_active_effect = active_effect;
+ var parent = derived.parent;
+
+ if (!is_destroying_effect && parent !== null && (parent.f & (DESTROYED | INERT)) !== 0) {
+ w.derived_inert();
+
+ return derived.v;
+ }
- set_active_effect(get_derived_parent_effect(derived));
+ set_active_effect(parent);
if (DEV) {
let prev_eager_effects = eager_effects;
diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts
index 8477917991..0ee8570c3d 100644
--- a/packages/svelte/src/internal/client/reactivity/types.d.ts
+++ b/packages/svelte/src/internal/client/reactivity/types.d.ts
@@ -57,8 +57,8 @@ export interface Derived extends Value, Reaction {
fn: () => V;
/** Effects created inside this signal. Used to destroy those effects when the derived reruns or is cleaned up */
effects: null | Effect[];
- /** Parent effect or derived */
- parent: Effect | Derived | null;
+ /** Parent effect */
+ parent: Effect | null;
}
export interface EffectNodes {
diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js
index a9a50c57d6..f4e605ac96 100644
--- a/packages/svelte/src/internal/client/warnings.js
+++ b/packages/svelte/src/internal/client/warnings.js
@@ -74,6 +74,17 @@ export function console_log_state(method) {
}
}
+/**
+ * Reading a derived belonging to a now-destroyed effect may result in stale values
+ */
+export function derived_inert() {
+ if (DEV) {
+ console.warn(`%c[svelte] derived_inert\n%cReading a derived belonging to a now-destroyed effect may result in stale values\nhttps://svelte.dev/e/derived_inert`, bold, normal);
+ } else {
+ console.warn(`https://svelte.dev/e/derived_inert`);
+ }
+}
+
/**
* %handler% should be a function. Did you mean to %suggestion%?
* @param {string} handler
diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts
index 5486ccdb45..927ce2e665 100644
--- a/packages/svelte/tests/signals/test.ts
+++ b/packages/svelte/tests/signals/test.ts
@@ -1391,8 +1391,12 @@ describe('signals', () => {
};
});
- test('derived whose original parent effect has been destroyed keeps updating', () => {
+ test('derived whose original parent effect has been destroyed no longer updates', () => {
return () => {
+ const warn = console.warn;
+ const warnings: string[] = [];
+ console.warn = (...args) => warnings.push(...args);
+
let count: Source;
let double: Derived;
const destroy = effect_root(() => {
@@ -1404,17 +1408,23 @@ describe('signals', () => {
flushSync();
assert.equal($.get(double!), 0);
-
- destroy();
flushSync();
set(count!, 1);
flushSync();
assert.equal($.get(double!), 2);
+ destroy();
+
+ assert.equal($.get(double!), 2);
+ assert.equal(warnings.length, 0); // value is unchanged, no warning yet
+
set(count!, 2);
flushSync();
- assert.equal($.get(double!), 4);
+ assert.equal($.get(double!), 2);
+ assert.ok(warnings.some((str) => str.includes('derived_inert')));
+
+ console.warn = warn;
};
});
From 15588f5fbfe736f65e189e56047ee08678f5509f Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Fri, 10 Apr 2026 03:05:26 +0200
Subject: [PATCH 61/92] fix: avoid false positives for reactivity loss warning
(#18088)
Avoids two categories of false positives for reactivity loss warning:
1. if you have synchronously read signals already as part of invoking
the async_derived function, then it shouldn't warn when these signals
are read after an await if we know they haven't changed (we check the
write version for that)
2. `track_reactivity_loss` kept the `reactivity_loss_tracker` around
indefinitely, both when invoking the async operation as well as when
it's finished. The former is buggy because while the async operation
happens unrelated reads as part of other reactivity work can happen, the
latter is buggy because if it's the last in a chain of awaits it's kept
around until the next async work starts.
Fixes https://github.com/sveltejs/kit/issues/15654
---------
Co-authored-by: Rich Harris
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
---
.changeset/twenty-snakes-design.md | 5 +++
.../src/internal/client/reactivity/async.js | 18 +++++++++
.../internal/client/reactivity/deriveds.js | 37 ++++++++++++++-----
.../svelte/src/internal/client/runtime.js | 7 ++--
.../_config.js | 27 ++++++++++++++
.../main.svelte | 27 ++++++++++++++
.../_config.js | 2 +-
.../_config.js | 2 +-
.../_config.js | 21 +++++++++++
.../main.svelte | 16 ++++++++
.../_config.js | 19 ++++++++++
.../main.svelte | 14 +++++++
.../_config.js | 19 ++++++++++
.../main.svelte | 14 +++++++
14 files changed, 213 insertions(+), 15 deletions(-)
create mode 100644 .changeset/twenty-snakes-design.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte
diff --git a/.changeset/twenty-snakes-design.md b/.changeset/twenty-snakes-design.md
new file mode 100644
index 0000000000..ec1ac591f0
--- /dev/null
+++ b/.changeset/twenty-snakes-design.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: avoid false positives for reactivity loss warning
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 18e4f71c88..6aea790c36 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -164,10 +164,26 @@ export async function save(promise) {
*/
export async function track_reactivity_loss(promise) {
var previous_async_effect = reactivity_loss_tracker;
+ // Ensure that unrelated reads after an async operation is kicked off don't cause false positives
+ queueMicrotask(() => {
+ if (reactivity_loss_tracker === previous_async_effect) {
+ set_reactivity_loss_tracker(null);
+ }
+ });
+
var value = await promise;
return () => {
set_reactivity_loss_tracker(previous_async_effect);
+ // While this can result in false negatives it also guards against the more important
+ // false positives that would occur if this is the last in a chain of async operations,
+ // and the reactivity_loss_tracker would then stay around until the next async operation happens.
+ queueMicrotask(() => {
+ if (reactivity_loss_tracker === previous_async_effect) {
+ set_reactivity_loss_tracker(null);
+ }
+ });
+
return value;
};
}
@@ -206,7 +222,9 @@ export async function* for_await_track_reactivity_loss(iterable) {
normal_completion = true;
break;
}
+ var prev = reactivity_loss_tracker;
yield value;
+ set_reactivity_loss_tracker(prev);
}
} finally {
// If the iterator had an abrupt completion and `return` is defined on the iterator, call it and return the value
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 12dd7bf672..5af51449ad 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -1,4 +1,4 @@
-/** @import { Derived, Effect, Source } from '#client' */
+/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
/** @import { Batch } from './batch.js'; */
/** @import { Boundary } from '../dom/blocks/boundary.js'; */
import { DEV } from 'esm-env';
@@ -24,7 +24,9 @@ import {
push_reaction_value,
is_destroying_effect,
update_effect,
- remove_reactions
+ remove_reactions,
+ skipped_deps,
+ new_deps
} from '../runtime.js';
import { equals, safe_equals } from './equality.js';
import * as e from '../errors.js';
@@ -49,11 +51,11 @@ import { set_signal_status, update_derived_status } from './status.js';
/**
* This allows us to track 'reactivity loss' that occurs when signals
* are read after a non-context-restoring `await`. Dev-only
- * @type {{ effect: Effect, warned: boolean } | null}
+ * @type {{ effect: Effect, effect_deps: Set, warned: boolean } | null}
*/
export let reactivity_loss_tracker = null;
-/** @param {{ effect: Effect, warned: boolean } | null} v */
+/** @param {{ effect: Effect, effect_deps: Set, warned: boolean } | null} v */
export function set_reactivity_loss_tracker(v) {
reactivity_loss_tracker = v;
}
@@ -125,15 +127,12 @@ export function async_derived(fn, label, location) {
var deferreds = new Map();
async_effect(() => {
+ var effect = /** @type {Effect} */ (active_effect);
+
if (DEV) {
- reactivity_loss_tracker = {
- effect: /** @type {Effect} */ (active_effect),
- warned: false
- };
+ reactivity_loss_tracker = { effect, effect_deps: new Set(), warned: false };
}
- var effect = /** @type {Effect} */ (active_effect);
-
/** @type {ReturnType>} */
var d = deferred();
promise = d.promise;
@@ -149,6 +148,24 @@ export function async_derived(fn, label, location) {
}
if (DEV) {
+ if (reactivity_loss_tracker) {
+ // Reused deps from previous run (indices 0 to skipped_deps-1)
+ // We deliberately only track direct dependencies of the async expression to encourage
+ // dependencies being directly visible at the point of the expression
+ if (effect.deps !== null) {
+ for (let i = 0; i < skipped_deps; i += 1) {
+ reactivity_loss_tracker.effect_deps.add(effect.deps[i]);
+ }
+ }
+
+ // New deps discovered this run
+ if (new_deps !== null) {
+ for (let i = 0; i < new_deps.length; i += 1) {
+ reactivity_loss_tracker.effect_deps.add(new_deps[i]);
+ }
+ }
+ }
+
reactivity_loss_tracker = null;
}
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index d9578142eb..033afe98dc 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -111,9 +111,9 @@ export function push_reaction_value(value) {
* and until a new dependency is accessed — we track this via `skipped_deps`
* @type {null | Value[]}
*/
-let new_deps = null;
+export let new_deps = null;
-let skipped_deps = 0;
+export let skipped_deps = 0;
/**
* Tracks writes that the effect it's executed in doesn't listen to yet,
@@ -580,7 +580,8 @@ export function get(signal) {
!untracking &&
reactivity_loss_tracker &&
!reactivity_loss_tracker.warned &&
- (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0
+ (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 &&
+ !reactivity_loss_tracker.effect_deps.has(signal)
) {
reactivity_loss_tracker.warned = true;
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js
new file mode 100644
index 0000000000..2bec9e93a3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js
@@ -0,0 +1,27 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+import { normalise_trace_logs } from '../../../helpers.js';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+
+ async test({ assert, target, warnings }) {
+ await new Promise((r) => setTimeout(r, 25));
+
+ const [count] = target.querySelectorAll('button');
+
+ count.click();
+ await new Promise((r) => setTimeout(r, 25));
+
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `other`. This happens when state is read in an async function after an earlier `await`'
+ },
+ {
+ log: 'Detected reactivity loss when reading `other`. 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-async-after-sync/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte
new file mode 100644
index 0000000000..d3bcc199a4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte
@@ -0,0 +1,27 @@
+
+
+ count++}>{count}
+ other++}>{other}
+{await get()}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js
index 3429380800..7fb54d763c 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js
@@ -14,7 +14,7 @@ export default test({
assert.deepEqual(normalise_trace_logs(warnings), [
{
- log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`'
+ 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/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
index a5dd7fa28a..ddc9cef27d 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
@@ -15,7 +15,7 @@ export default test({
assert.deepEqual(normalise_trace_logs(warnings), [
{
- log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`'
+ 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-no-false-positive-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js
new file mode 100644
index 0000000000..516fa59a97
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js
@@ -0,0 +1,21 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+
+ async test({ assert, target, warnings }) {
+ await tick();
+
+ const [x, y] = target.querySelectorAll('button');
+
+ y.click();
+ await tick();
+ x.click();
+ await new Promise((r) => setTimeout(r, 15));
+
+ assert.equal(warnings.length, 0);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte
new file mode 100644
index 0000000000..9af5206705
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte
@@ -0,0 +1,16 @@
+
+
+{x} {await foo(y)}
+
+ x += 1}>x++
+ y += 1}>y++
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js
new file mode 100644
index 0000000000..6c774609e5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js
@@ -0,0 +1,19 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+
+ async test({ assert, target, warnings }) {
+ await tick();
+
+ const [count] = target.querySelectorAll('button');
+
+ count.click();
+ await tick();
+
+ assert.equal(warnings.length, 0);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte
new file mode 100644
index 0000000000..ea7e9dcc9b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte
@@ -0,0 +1,14 @@
+
+
+{await delay(count)}
+
+ count += 1}>count++
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js
new file mode 100644
index 0000000000..c9c02336bc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js
@@ -0,0 +1,19 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+
+ async test({ assert, target, warnings }) {
+ await new Promise((r) => setTimeout(r, 5));
+
+ const [count] = target.querySelectorAll('button');
+
+ count.click();
+ await tick();
+
+ assert.equal(warnings.length, 0);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte
new file mode 100644
index 0000000000..a016655b7c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte
@@ -0,0 +1,14 @@
+
+
+ count++}>{count}
+{await get()}
From 4a50e8ea3b7db1d8cd752b825032e4ce2878524b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 09:22:42 -0400
Subject: [PATCH 62/92] Version Packages (#18085)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.55.3
### Patch Changes
- fix: ensure proper HMR updates for dynamic components
([#18079](https://github.com/sveltejs/svelte/pull/18079))
- fix: correctly calculate `@const` blockers
([#18039](https://github.com/sveltejs/svelte/pull/18039))
- fix: freeze deriveds once their containing effects are destroyed
([#17921](https://github.com/sveltejs/svelte/pull/17921))
- fix: defer error boundary rendering in forks
([#18076](https://github.com/sveltejs/svelte/pull/18076))
- fix: avoid false positives for reactivity loss warning
([#18088](https://github.com/sveltejs/svelte/pull/18088))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/all-masks-dig.md | 5 -----
.changeset/common-flowers-listen.md | 5 -----
.changeset/silly-phones-follow.md | 5 -----
.changeset/slimy-pears-throw.md | 5 -----
.changeset/twenty-snakes-design.md | 5 -----
packages/svelte/CHANGELOG.md | 14 ++++++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
8 files changed, 16 insertions(+), 27 deletions(-)
delete mode 100644 .changeset/all-masks-dig.md
delete mode 100644 .changeset/common-flowers-listen.md
delete mode 100644 .changeset/silly-phones-follow.md
delete mode 100644 .changeset/slimy-pears-throw.md
delete mode 100644 .changeset/twenty-snakes-design.md
diff --git a/.changeset/all-masks-dig.md b/.changeset/all-masks-dig.md
deleted file mode 100644
index ffa40655b9..0000000000
--- a/.changeset/all-masks-dig.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: ensure proper HMR updates for dynamic components
diff --git a/.changeset/common-flowers-listen.md b/.changeset/common-flowers-listen.md
deleted file mode 100644
index 8d021b8054..0000000000
--- a/.changeset/common-flowers-listen.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: correctly calculate `@const` blockers
diff --git a/.changeset/silly-phones-follow.md b/.changeset/silly-phones-follow.md
deleted file mode 100644
index c8671c6abe..0000000000
--- a/.changeset/silly-phones-follow.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: freeze deriveds once their containing effects are destroyed
diff --git a/.changeset/slimy-pears-throw.md b/.changeset/slimy-pears-throw.md
deleted file mode 100644
index c9954586e5..0000000000
--- a/.changeset/slimy-pears-throw.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: defer error boundary rendering in forks
diff --git a/.changeset/twenty-snakes-design.md b/.changeset/twenty-snakes-design.md
deleted file mode 100644
index ec1ac591f0..0000000000
--- a/.changeset/twenty-snakes-design.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: avoid false positives for reactivity loss warning
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 0853fab898..0e804235fc 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,19 @@
# svelte
+## 5.55.3
+
+### Patch Changes
+
+- fix: ensure proper HMR updates for dynamic components ([#18079](https://github.com/sveltejs/svelte/pull/18079))
+
+- fix: correctly calculate `@const` blockers ([#18039](https://github.com/sveltejs/svelte/pull/18039))
+
+- fix: freeze deriveds once their containing effects are destroyed ([#17921](https://github.com/sveltejs/svelte/pull/17921))
+
+- fix: defer error boundary rendering in forks ([#18076](https://github.com/sveltejs/svelte/pull/18076))
+
+- fix: avoid false positives for reactivity loss warning ([#18088](https://github.com/sveltejs/svelte/pull/18088))
+
## 5.55.2
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index a6c07ec75e..d06c3e3941 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.55.2",
+ "version": "5.55.3",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index dbca16d6a2..02695cf663 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.55.2';
+export const VERSION = '5.55.3';
export const PUBLIC_VERSION = '5';
From 273f1a85a4dbe2937f2d97afa2511e828eb8ebba Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 13 Apr 2026 11:33:02 +0200
Subject: [PATCH 63/92] fix: keep flushing new eager effects (#18102)
The previous code "swallowed" new additions to the array of eager
effects that happened while flushing since `eager_flush` did not clear
the array before running, only afterwards. Now it clears the beforehand,
causing newly added eager effects to run, too.
An example where this can happen is `$state.eager` and `$effect.pending`
in combination: first `$state.eager` is flushed, then due to `flushSync`
the `queue_micro_task` inside `boundary.js` that flushes
`$effect.pending` is triggered synchronously, adding new entries to the
`eager_versions` array. If they're only cleared at the end of
`eager_flush`, new entries are swallowed.
Related to #18095 (but not fixing it yet)
---
.changeset/sweet-boxes-unite.md | 5 +++
.../src/internal/client/reactivity/batch.js | 14 ++++----
.../async-effect-pending-eager/_config.js | 35 +++++++++++++++++++
.../async-effect-pending-eager/main.svelte | 27 ++++++++++++++
4 files changed, 73 insertions(+), 8 deletions(-)
create mode 100644 .changeset/sweet-boxes-unite.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte
diff --git a/.changeset/sweet-boxes-unite.md b/.changeset/sweet-boxes-unite.md
new file mode 100644
index 0000000000..f58ca436be
--- /dev/null
+++ b/.changeset/sweet-boxes-unite.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: keep flushing new eager effects
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 68aeb66f93..82be1d1e8d 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -1074,15 +1074,13 @@ export function schedule_effect(effect) {
let eager_versions = [];
function eager_flush() {
- try {
- flushSync(() => {
- for (const version of eager_versions) {
- update(version);
- }
- });
- } finally {
+ flushSync(() => {
+ const eager = eager_versions;
eager_versions = [];
- }
+ for (const version of eager) {
+ update(version);
+ }
+ });
}
/**
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js
new file mode 100644
index 0000000000..b37b3c9228
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js
@@ -0,0 +1,35 @@
+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,
+ `
+ increment
+ shift
+ 1
+ pending: 1
+ loading...
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ increment
+ shift
+ 1
+ pending: 0
+ 1
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte
new file mode 100644
index 0000000000..535968a46a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte
@@ -0,0 +1,27 @@
+
+
+ value++}>increment
+ queued.shift()?.()}>shift
+
+{$state.eager(value)}
+
+{#if 1}
+ pending: {$effect.pending()}
+
+ {@const tmp = await delayed(value)}
+ {#if $effect.pending() > 0}
+ loading...
+ {:else}
+ {tmp}
+ {/if}
+{/if}
From 0ed8c282f96960f52eaf077ffbe6e53c181b3774 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 13 Apr 2026 20:09:26 +0200
Subject: [PATCH 64/92] fix: reset context after waiting on blockers of
`@const` expressions (#18100)
Regression from #18039 - we need to have each await expression (and
waiting on blockers is one) in its own entry of `(renderer.)run`. Else
context is not restored correctly and if the synchronous expression
afterwards requires it stuff breaks.
Fixes #18098
---
.changeset/soft-moons-wear.md | 5 +++++
.../src/compiler/phases/2-analyze/types.d.ts | 2 +-
.../phases/2-analyze/visitors/ConstTag.js | 3 ++-
.../3-transform/client/visitors/ConstTag.js | 13 +++---------
.../3-transform/server/visitors/ConstTag.js | 13 +++---------
.../_config.js | 11 ++++++++++
.../main.svelte | 11 ++++++++++
.../_expected/client/index.svelte.js | 21 ++++++++++++++++++-
.../_expected/server/index.svelte.js | 11 ++++++++++
.../samples/async-in-derived/index.svelte | 4 ++++
10 files changed, 71 insertions(+), 23 deletions(-)
create mode 100644 .changeset/soft-moons-wear.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte
diff --git a/.changeset/soft-moons-wear.md b/.changeset/soft-moons-wear.md
new file mode 100644
index 0000000000..eeb2b14b8c
--- /dev/null
+++ b/.changeset/soft-moons-wear.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reset context after waiting on blockers of `@const` expressions
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 7941113a7f..354f1c0856 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
+++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
@@ -38,7 +38,7 @@ export interface AnalysisState {
/** Collected info about async `{@const }` declarations */
async_consts?: {
id: Identifier;
- /** How many `@const` declarations there are (already) in this scope */
+ /** How many `$.run(...)` entries are already allocated in this scope */
declaration_count: number;
};
}
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 22ffc24c1e..4f07249a39 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
@@ -61,7 +61,8 @@ export function ConstTag(node, context) {
// keep the counter in sync with the number of thunks pushed in ConstTag in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
- const length = run.declaration_count++;
+ const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
+ run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
index ac8f120d3d..bf559cd24d 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
@@ -96,22 +96,15 @@ function add_const_declaration(state, id, expression, metadata) {
state.consts.push(b.let(id));
- /** @type {Statement | undefined} */
- let promise_stmt;
-
if (blockers.length === 1) {
- promise_stmt = b.stmt(b.await(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
+ run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
- promise_stmt = b.stmt(b.await(b.call('$.wait', b.array(blockers))));
+ run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, expression);
- if (promise_stmt) {
- run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true));
- } else {
- run.thunks.push(b.thunk(assignment, metadata.expression.has_await));
- }
+ run.thunks.push(b.thunk(assignment, metadata.expression.has_await));
} else {
state.consts.push(b.const(id, expression));
state.consts.push(...after);
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
index 87d60442f6..9420bdd6d2 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
@@ -28,22 +28,15 @@ export function ConstTag(node, context) {
context.state.init.push(b.let(identifier.name));
}
- /** @type {Statement | undefined} */
- let promise_stmt;
-
if (blockers.length === 1) {
- promise_stmt = b.stmt(b.await(/** @type {Expression} */ (blockers[0])));
+ run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
- promise_stmt = b.stmt(b.await(b.call('Promise.all', b.array(blockers))));
+ run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, init);
- if (promise_stmt) {
- run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true));
- } else {
- run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await));
- }
+ run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await));
} else {
context.state.init.push(b.const(id, init));
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js
new file mode 100644
index 0000000000..6c202c1ed6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js
@@ -0,0 +1,11 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+// Tests that context is restored after await (const has to wait on a blocker), so that getContext etc work correctly
+export default test({
+ mode: ['hydrate', 'async-server', 'client'],
+ async test({ assert, target }) {
+ await tick();
+ assert.htmlEqual(target.innerHTML, 'hi');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte
new file mode 100644
index 0000000000..be2d0dc7be
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte
@@ -0,0 +1,11 @@
+
+
+{#if true}
+ {@const foo = bar}
+ {foo}
+{/if}
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
index 4f06d9ddbf..79b8ad0040 100644
--- 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
@@ -2,6 +2,8 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/async';
import * as $ from 'svelte/internal/client';
+var root = $.from_html(` `, 1);
+
export default function Async_in_derived($$anchor, $$props) {
$.push($$props, true);
@@ -21,7 +23,7 @@ export default function Async_in_derived($$anchor, $$props) {
}
]);
- var fragment = $.comment();
+ var fragment = root();
var node = $.first_child(fragment);
{
@@ -49,6 +51,23 @@ export default function Async_in_derived($$anchor, $$props) {
});
}
+ var node_1 = $.sibling(node, 2);
+
+ {
+ var consequent_1 = ($$anchor) => {
+ let x;
+
+ var promises_1 = $.run([
+ () => $$promises[2].promise,
+ () => x = $.derived(() => $.get(no2))
+ ]);
+ };
+
+ $.if(node_1, ($$render) => {
+ if (true) $$render(consequent_1);
+ });
+ }
+
$.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
index 0cebc412ef..651a92f9a8 100644
--- 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
@@ -42,6 +42,17 @@ export default function Async_in_derived($$renderer, $$props) {
$$renderer.push('');
}
+ $$renderer.push(` `);
+
+ if (true) {
+ $$renderer.push('');
+
+ let x;
+ var promises_1 = $$renderer.run([() => $$promises[2], () => x = no2()]);
+ } 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
index bda88fd3ae..b105bef189 100644
--- a/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte
+++ b/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte
@@ -19,3 +19,7 @@
return await 1;
})()}
{/if}
+
+{#if true}
+ {@const x = no2}
+{/if}
From 671fc2ea11b56f050f37f7e03564fb070bc8abea Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Tue, 14 Apr 2026 14:47:56 +0200
Subject: [PATCH 65/92] fix: never mark a child effect root as inert (#18111)
A nested `$effect.root` was marked `INERT` during `pause_children`,
which caused it to stay in that state indefinetly after the rest of the
parent tree was destroyed. Consequently deriveds inside no longer update
and cause warnings.
This fixes it by not marking nested `$effect.root`s as inert, just like
nested `$effect.root`s are not destryoed and instead become a new root.
Fixes #18097
---
.changeset/fresh-chicken-itch.md | 5 ++++
.../src/internal/client/reactivity/effects.js | 26 ++++++++++++-------
.../samples/effect-root-6/Child.svelte | 14 ++++++++++
.../samples/effect-root-6/_config.js | 14 ++++++++++
.../samples/effect-root-6/main.svelte | 15 +++++++++++
5 files changed, 64 insertions(+), 10 deletions(-)
create mode 100644 .changeset/fresh-chicken-itch.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte
diff --git a/.changeset/fresh-chicken-itch.md b/.changeset/fresh-chicken-itch.md
new file mode 100644
index 0000000000..95120e1591
--- /dev/null
+++ b/.changeset/fresh-chicken-itch.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: never mark a child effect root as inert
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index ea8a4b645e..0fad074e6f 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -654,16 +654,22 @@ function pause_children(effect, transitions, local) {
while (child !== null) {
var sibling = child.next;
- var transparent =
- (child.f & EFFECT_TRANSPARENT) !== 0 ||
- // If this is a branch effect without a block effect parent,
- // it means the parent block effect was pruned. In that case,
- // transparency information was transferred to the branch effect.
- ((child.f & BRANCH_EFFECT) !== 0 && (effect.f & BLOCK_EFFECT) !== 0);
- // TODO we don't need to call pause_children recursively with a linked list in place
- // it's slightly more involved though as we have to account for `transparent` changing
- // through the tree.
- pause_children(child, transitions, transparent ? local : false);
+
+ // If this child is a root effect, then it will become an independent root when its parent
+ // is destroyed, it should therefore not become inert nor partake in transitions.
+ if ((child.f & ROOT_EFFECT) === 0) {
+ var transparent =
+ (child.f & EFFECT_TRANSPARENT) !== 0 ||
+ // If this is a branch effect without a block effect parent,
+ // it means the parent block effect was pruned. In that case,
+ // transparency information was transferred to the branch effect.
+ ((child.f & BRANCH_EFFECT) !== 0 && (effect.f & BLOCK_EFFECT) !== 0);
+ // TODO we don't need to call pause_children recursively with a linked list in place
+ // it's slightly more involved though as we have to account for `transparent` changing
+ // through the tree.
+ pause_children(child, transitions, transparent ? local : false);
+ }
+
child = sibling;
}
}
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte b/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte
new file mode 100644
index 0000000000..110ad82b0d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte
@@ -0,0 +1,14 @@
+
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js
new file mode 100644
index 0000000000..e4077c6c27
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js
@@ -0,0 +1,14 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+// Test that $effect.root continues to be operational after its parent effect has been destroyed
+export default test({
+ test({ assert, target, logs }) {
+ const [hide, increment] = target.querySelectorAll('button');
+
+ hide.click();
+ flushSync();
+ increment.click();
+ assert.deepEqual(logs, ['count', 1, 'double', 2]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte
new file mode 100644
index 0000000000..254cddeb5b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte
@@ -0,0 +1,15 @@
+
+
+ show = false}>hide
+ increment()}>increment
+{#if show}
+
+{/if}
From 7fddfbdbbde8813ee107d56f70f5ea6c3d3abbc3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 14 Apr 2026 15:25:04 +0200
Subject: [PATCH 66/92] Version Packages (#18105)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.55.4
### Patch Changes
- fix: never mark a child effect root as inert
([#18111](https://github.com/sveltejs/svelte/pull/18111))
- fix: reset context after waiting on blockers of `@const` expressions
([#18100](https://github.com/sveltejs/svelte/pull/18100))
- fix: keep flushing new eager effects
([#18102](https://github.com/sveltejs/svelte/pull/18102))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/fresh-chicken-itch.md | 5 -----
.changeset/soft-moons-wear.md | 5 -----
.changeset/sweet-boxes-unite.md | 5 -----
packages/svelte/CHANGELOG.md | 10 ++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
6 files changed, 12 insertions(+), 17 deletions(-)
delete mode 100644 .changeset/fresh-chicken-itch.md
delete mode 100644 .changeset/soft-moons-wear.md
delete mode 100644 .changeset/sweet-boxes-unite.md
diff --git a/.changeset/fresh-chicken-itch.md b/.changeset/fresh-chicken-itch.md
deleted file mode 100644
index 95120e1591..0000000000
--- a/.changeset/fresh-chicken-itch.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: never mark a child effect root as inert
diff --git a/.changeset/soft-moons-wear.md b/.changeset/soft-moons-wear.md
deleted file mode 100644
index eeb2b14b8c..0000000000
--- a/.changeset/soft-moons-wear.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: reset context after waiting on blockers of `@const` expressions
diff --git a/.changeset/sweet-boxes-unite.md b/.changeset/sweet-boxes-unite.md
deleted file mode 100644
index f58ca436be..0000000000
--- a/.changeset/sweet-boxes-unite.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: keep flushing new eager effects
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 0e804235fc..7aa8f9818e 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,15 @@
# svelte
+## 5.55.4
+
+### Patch Changes
+
+- fix: never mark a child effect root as inert ([#18111](https://github.com/sveltejs/svelte/pull/18111))
+
+- fix: reset context after waiting on blockers of `@const` expressions ([#18100](https://github.com/sveltejs/svelte/pull/18100))
+
+- fix: keep flushing new eager effects ([#18102](https://github.com/sveltejs/svelte/pull/18102))
+
## 5.55.3
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index d06c3e3941..12aa895ecf 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.55.3",
+ "version": "5.55.4",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 02695cf663..cb8d3f76ab 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.55.3';
+export const VERSION = '5.55.4';
export const PUBLIC_VERSION = '5';
From 6a149666c1772b443c0db9385ee7521275b27487 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 15 Apr 2026 13:34:30 +0200
Subject: [PATCH 67/92] chore: enhance download script to ask for folder
(#18112)
If project that is downloaded is a project that can't be transfered into
the playground (because it relies on SvelteKit stuff), instead of
failing it now asks if you want to put it into another folder within the
playgrounds folder
---
playgrounds/sandbox/scripts/download.js | 94 ++++++++++++++++++++++---
1 file changed, 85 insertions(+), 9 deletions(-)
diff --git a/playgrounds/sandbox/scripts/download.js b/playgrounds/sandbox/scripts/download.js
index 538b4956fe..5b915c8abd 100644
--- a/playgrounds/sandbox/scripts/download.js
+++ b/playgrounds/sandbox/scripts/download.js
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { parseArgs } from 'node:util';
import { execSync } from 'node:child_process';
+import readline from 'node:readline/promises';
import { chromium } from 'playwright';
const { values, positionals } = parseArgs({
@@ -546,9 +547,9 @@ function convert_vite_project(repo_dir) {
/**
* Process a local or cloned directory
* @param {string} dir_path
- * @returns {Array<{name: string, contents: string}>}
+ * @returns {Promise | null>}
*/
-function process_directory(dir_path) {
+async function process_directory(dir_path) {
const all_files = get_all_files(dir_path);
const project_info = detect_project_type(all_files);
@@ -558,7 +559,18 @@ function process_directory(dir_path) {
if (project_info.has_app_imports) {
console.error('Error: This SvelteKit project uses $app/* imports which cannot be converted.');
console.error('The playground does not support SvelteKit runtime features.');
- process.exit(1);
+
+ const fallback_dir = path.resolve(base_dir, '..', '..', 'kit-sandbox-tmp');
+ const should_copy = await prompt_download_to_kit_sandbox_tmp(fallback_dir);
+
+ if (!should_copy) {
+ process.exit(1);
+ }
+
+ copy_project_to_directory(dir_path, fallback_dir);
+ console.log(`Project copied to ${fallback_dir}`);
+
+ return null;
}
// Convert based on project type
@@ -571,6 +583,66 @@ function process_directory(dir_path) {
}
}
+/**
+ * Ask whether to copy the project to playgrounds/kit-sandbox-tmp
+ * @param {string} fallback_dir
+ * @returns {Promise}
+ */
+async function prompt_download_to_kit_sandbox_tmp(fallback_dir) {
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
+ return false;
+ }
+
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+
+ try {
+ const answer = await rl.question(
+ `Would you like to copy this project into ${fallback_dir} instead? [y/N] `
+ );
+ return /^(y|yes)$/i.test(answer.trim());
+ } finally {
+ rl.close();
+ }
+}
+
+/**
+ * Copy a project directory while skipping generated and dependency folders
+ * @param {string} source_dir
+ * @param {string} target_dir
+ */
+function copy_project_to_directory(source_dir, target_dir) {
+ const skip_dirs = new Set(['node_modules', '.git', '.svelte-kit', 'build', 'dist']);
+
+ if (fs.existsSync(target_dir)) {
+ fs.rmSync(target_dir, { recursive: true, force: true });
+ }
+
+ /** @param {string} from_dir */
+ function copy_recursive(from_dir) {
+ const relative_dir = path.relative(source_dir, from_dir);
+ const to_dir = relative_dir ? path.join(target_dir, relative_dir) : target_dir;
+
+ fs.mkdirSync(to_dir, { recursive: true });
+
+ for (const entry of fs.readdirSync(from_dir, { withFileTypes: true })) {
+ if (entry.isDirectory() && skip_dirs.has(entry.name)) {
+ continue;
+ }
+
+ const source_path = path.join(from_dir, entry.name);
+ const target_path = path.join(to_dir, entry.name);
+
+ if (entry.isDirectory()) {
+ copy_recursive(source_path);
+ } else if (entry.isFile()) {
+ fs.copyFileSync(source_path, target_path);
+ }
+ }
+ }
+
+ copy_recursive(source_dir);
+}
+
/**
* Reset a directory so it exists and is empty
* @param {string} dir_path
@@ -602,7 +674,7 @@ let files;
// Check if it's a local directory first (before URL parsing)
if (is_local) {
console.log(`Processing local directory: ${url_arg}`);
- files = process_directory(url_arg);
+ files = await process_directory(url_arg);
} else if (resolved_test_path) {
// Copy files from test
console.log(`Processing test ${url_arg}`);
@@ -616,21 +688,21 @@ if (is_local) {
});
} else if (url && is_github_url(url)) {
// GitHub repository handling
- await with_tmp_dir(base_dir, (tmp_dir) => {
+ await with_tmp_dir(base_dir, async (tmp_dir) => {
clone_github_repo(url, tmp_dir);
- files = process_directory(tmp_dir);
+ files = await process_directory(tmp_dir);
});
} else if (url && is_stackblitz_github_url(url)) {
// StackBlitz GitHub project handling (redirect to GitHub clone)
- await with_tmp_dir(base_dir, (tmp_dir) => {
+ await with_tmp_dir(base_dir, async (tmp_dir) => {
clone_stackblitz_github_project(url, tmp_dir);
- files = process_directory(tmp_dir);
+ files = await process_directory(tmp_dir);
});
} else if (url && is_stackblitz_edit_url(url)) {
// StackBlitz edit URLs - use browser automation to download
await with_tmp_dir(base_dir, async (tmp_dir) => {
await download_stackblitz_project(url, tmp_dir);
- files = process_directory(tmp_dir);
+ files = await process_directory(tmp_dir);
});
} else if (url && url.origin === 'https://svelte.dev' && url.pathname.startsWith('/playground/')) {
// Svelte playground URL handling (existing logic)
@@ -680,6 +752,10 @@ if (is_local) {
process.exit(1);
}
+if (files === null) {
+ process.exit(0);
+}
+
// Output files
if (create_test_name) {
const test_parts = create_test_name.split('/').filter(Boolean);
From 48dc9b40c0249ffba7067f3c3605e4f1fb376b5e Mon Sep 17 00:00:00 2001
From: Rohan Santhosh Kumar <181558744+Rohan5commit@users.noreply.github.com>
Date: Mon, 20 Apr 2026 20:12:58 +0800
Subject: [PATCH 68/92] docs: fix spacing around option tag in bind docs
(#18114)
## Summary
- Fix the missing space before `` in the bind documentation.
## Related issue
- N/A
## Guideline alignment
- Read `CONTRIBUTING.md` and kept this to one focused docs-only file
change.
- No behavior, test, fixture, changeset, or generated-file changes.
## Test plan
- `git diff --check`
- Not run: docs-only spacing fix.
Co-authored-by: Codex
---
documentation/docs/03-template-syntax/12-bind.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md
index e8164149db..b93f207e71 100644
--- a/documentation/docs/03-template-syntax/12-bind.md
+++ b/documentation/docs/03-template-syntax/12-bind.md
@@ -241,7 +241,7 @@ When the value of an `` matches its text content, the attribute can be o
```
-You can give the `` a default value by adding a `selected` attribute to the`` (or options, in the case of ``) that should be initially selected. If the `` is part of a form, it will revert to that selection when the form is reset. Note that for the initial render the value of the binding takes precedence if it's not `undefined`.
+You can give the `` a default value by adding a `selected` attribute to the `` (or options, in the case of ``) that should be initially selected. If the `` is part of a form, it will revert to that selection when the form is reset. Note that for the initial render the value of the binding takes precedence if it's not `undefined`.
```svelte
From 51736e576d86cc8879211632e3969730c513236b Mon Sep 17 00:00:00 2001
From: dabund24
Date: Mon, 20 Apr 2026 14:18:56 +0200
Subject: [PATCH 69/92] fix: do not dispatch transition event with animation
(#18122)
closes #18056
related: #17567 and #14009
# Changes
move `dispatch_event()` calls in `transitions.js` out of `animate()`
function using an additional `on_begin()` callback parameter. Doing so
makes it possible to dispatch the `introstart` and `outrostart` events
only from `transition()`.
# Testing
add a test checking that svelte dispatches no event when it runs an
animation
---
.changeset/hip-flowers-give.md | 5 ++
.../client/dom/elements/transitions.js | 68 +++++++++++++------
.../animate-no-transition-events/_config.js | 31 +++++++++
.../animate-no-transition-events/main.svelte | 19 ++++++
4 files changed, 102 insertions(+), 21 deletions(-)
create mode 100644 .changeset/hip-flowers-give.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/main.svelte
diff --git a/.changeset/hip-flowers-give.md b/.changeset/hip-flowers-give.md
new file mode 100644
index 0000000000..77f4dd8892
--- /dev/null
+++ b/.changeset/hip-flowers-give.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: do not dispatch introstart event with animation of animate directive
diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js
index 0c5431f030..7c4d385807 100644
--- a/packages/svelte/src/internal/client/dom/elements/transitions.js
+++ b/packages/svelte/src/internal/client/dom/elements/transitions.js
@@ -115,10 +115,17 @@ export function animation(element, get_fn, get_params) {
) {
const options = get_fn()(this.element, { from, to }, get_params?.());
- animation = animate(this.element, options, undefined, 1, () => {
- animation?.abort();
- animation = undefined;
- });
+ animation = animate(
+ this.element,
+ options,
+ undefined,
+ 1,
+ () => {},
+ () => {
+ animation?.abort();
+ animation = undefined;
+ }
+ );
}
},
fix() {
@@ -239,15 +246,24 @@ export function transition(flags, element, get_fn, get_params) {
intro?.abort();
}
- intro = animate(element, get_options(), outro, 1, () => {
- dispatch_event(element, 'introend');
-
- // Ensure we cancel the animation to prevent leaking
- intro?.abort();
- intro = current_options = undefined;
-
- element.style.overflow = overflow;
- });
+ intro = animate(
+ element,
+ get_options(),
+ outro,
+ 1,
+ () => {
+ dispatch_event(element, 'introstart');
+ },
+ () => {
+ dispatch_event(element, 'introend');
+
+ // Ensure we cancel the animation to prevent leaking
+ intro?.abort();
+ intro = current_options = undefined;
+
+ element.style.overflow = overflow;
+ }
+ );
},
out(fn) {
if (!is_outro) {
@@ -258,10 +274,19 @@ export function transition(flags, element, get_fn, get_params) {
element.inert = true;
- outro = animate(element, get_options(), intro, 0, () => {
- dispatch_event(element, 'outroend');
- fn?.();
- });
+ outro = animate(
+ element,
+ get_options(),
+ intro,
+ 0,
+ () => {
+ dispatch_event(element, 'outrostart');
+ },
+ () => {
+ dispatch_event(element, 'outroend');
+ fn?.();
+ }
+ );
},
stop: () => {
intro?.abort();
@@ -306,10 +331,11 @@ export function transition(flags, element, get_fn, get_params) {
* @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options
* @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro
* @param {number} t2 The target `t` value — `1` for intro, `0` for outro
+ * @param {(() => void)} on_begin Called just before beginning the animation
* @param {(() => void)} on_finish Called after successfully completing the animation
* @returns {Animation}
*/
-function animate(element, options, counterpart, t2, on_finish) {
+function animate(element, options, counterpart, t2, on_begin, on_finish) {
var is_intro = t2 === 1;
if (is_function(options)) {
@@ -323,7 +349,7 @@ function animate(element, options, counterpart, t2, on_finish) {
queue_micro_task(() => {
if (aborted) return;
var o = options({ direction: is_intro ? 'in' : 'out' });
- a = animate(element, o, counterpart, t2, on_finish);
+ a = animate(element, o, counterpart, t2, on_begin, on_finish);
});
// ...but we want to do so without using `async`/`await` everywhere, so
@@ -342,7 +368,7 @@ function animate(element, options, counterpart, t2, on_finish) {
counterpart?.deactivate();
if (!options?.duration && !options?.delay) {
- dispatch_event(element, is_intro ? 'introstart' : 'outrostart');
+ on_begin();
on_finish();
return {
@@ -382,7 +408,7 @@ function animate(element, options, counterpart, t2, on_finish) {
// remove dummy animation from the stack to prevent conflict with main animation
animation.cancel();
- dispatch_event(element, is_intro ? 'introstart' : 'outrostart');
+ on_begin();
// for bidirectional transitions, we start from the current position,
// rather than doing a full intro/outro
diff --git a/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/_config.js b/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/_config.js
new file mode 100644
index 0000000000..3144b53687
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/_config.js
@@ -0,0 +1,31 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ test({ assert, raf, target, logs }) {
+ let divs = target.querySelectorAll('div');
+ divs.forEach((div) => {
+ // @ts-expect-error
+ div.getBoundingClientRect = function () {
+ // @ts-expect-error
+ const index = [...this.parentNode.children].indexOf(this);
+ const top = index * 30;
+
+ return {
+ left: 0,
+ right: 100,
+ top,
+ bottom: top + 20
+ };
+ };
+ });
+
+ const [btn] = target.querySelectorAll('button');
+ flushSync(() => btn.click());
+
+ raf.tick(1);
+ assert.deepEqual(logs, []);
+ raf.tick(100);
+ assert.deepEqual(logs, []);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/main.svelte b/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/main.svelte
new file mode 100644
index 0000000000..a6ba765cf4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/animate-no-transition-events/main.svelte
@@ -0,0 +1,19 @@
+
+
+ numbers.reverse()}>reverse
+
+{#each numbers as num (num)}
+ console.log("intro start")}
+ onoutrostart={() => console.log("outro start")}
+ onintroend={() => console.log("intro end")}
+ onoutroend={() => console.log("outro end")}
+ animate:flip={{ duration: 100 }}
+ >
+ {num}
+
+{/each}
From 8e7319063aa609cca2cbf8cdf1958e5392dd2fa0 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:35:09 +0200
Subject: [PATCH 70/92] fix: don't mark deriveds while an effect is updating
(#18124)
Fixes #18123
This makes setting state inside effects slightly slower theoretically
(since they hit the new guard), but I verified that the original issue
for which we introduced this (#16658) is still fast with this change.
The more we add logic to this the more I think we should investigate
switching to a different mechanism. I tried using a `Set` previously but
it did hurt the benchmarks a bit - might try to revisit a variant of
this.
---------
Co-authored-by: github-actions[bot]
---
.changeset/common-candles-sneeze.md | 5 ++++
.../svelte/src/internal/client/constants.js | 3 +-
.../src/internal/client/reactivity/sources.js | 10 +++++--
.../Child.svelte | 7 +++++
.../_config.js | 29 +++++++++++++++++++
.../main.svelte | 14 +++++++++
.../store.svelte.js | 13 +++++++++
7 files changed, 77 insertions(+), 4 deletions(-)
create mode 100644 .changeset/common-candles-sneeze.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/store.svelte.js
diff --git a/.changeset/common-candles-sneeze.md b/.changeset/common-candles-sneeze.md
new file mode 100644
index 0000000000..a0ef7b610b
--- /dev/null
+++ b/.changeset/common-candles-sneeze.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't mark deriveds while an effect is updating
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index a3ad988ba1..f92fba73be 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -48,7 +48,8 @@ export const EFFECT_OFFSCREEN = 1 << 25;
/**
* Tells that we marked this derived and its reactions as visited during the "mark as (maybe) dirty"-phase.
* Will be lifted during execution of the derived and during checking its dirty state (both are necessary
- * because a derived might be checked but not executed).
+ * because a derived might be checked but not executed). This is a pure performance optimization flag and
+ * should not be used for any other purpose!
*/
export const WAS_MARKED = 1 << 16;
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 9235c5f673..1831183e6f 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -27,7 +27,8 @@ import {
ROOT_EFFECT,
ASYNC,
WAS_MARKED,
- CONNECTED
+ CONNECTED,
+ REACTION_IS_UPDATING
} from '#client/constants';
import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
@@ -356,8 +357,11 @@ function mark_reactions(signal, status, updated_during_traversal) {
batch_values?.delete(derived);
if ((flags & WAS_MARKED) === 0) {
- // Only connected deriveds can be reliably unmarked right away
- if (flags & CONNECTED) {
+ // Only connected deriveds being executed outside the update cycle can be reliably unmarked right away
+ if (
+ flags & CONNECTED &&
+ (active_effect === null || (active_effect.f & REACTION_IS_UPDATING) === 0)
+ ) {
reaction.f |= WAS_MARKED;
}
diff --git a/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/Child.svelte b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/Child.svelte
new file mode 100644
index 0000000000..9771762c0b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/Child.svelte
@@ -0,0 +1,7 @@
+
diff --git a/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/_config.js b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/_config.js
new file mode 100644
index 0000000000..60985cdd12
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/_config.js
@@ -0,0 +1,29 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [show, hide] = target.querySelectorAll('button');
+
+ hide.click();
+ flushSync();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ show
+ hide
+ `
+ );
+
+ show.click();
+ flushSync();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ show
+ hide
+ visible
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/main.svelte b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/main.svelte
new file mode 100644
index 0000000000..3359a6305b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/main.svelte
@@ -0,0 +1,14 @@
+
+
+ store.set("visible", true)}>show
+ store.set("visible", false)}>hide
+{#if visible2}
+
+ visible
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/store.svelte.js b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/store.svelte.js
new file mode 100644
index 0000000000..b48155e823
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/derived-dep-set-while-rendering/store.svelte.js
@@ -0,0 +1,13 @@
+class RawStore {
+ values = $state.raw({ visible: true });
+
+ get(key) {
+ return this.values[key];
+ }
+
+ set(key, value) {
+ this.values = { ...this.values, [key]: value };
+ }
+}
+
+export const store = new RawStore();
From b771df346444d486243882099d2a36f88e32dde0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:44:54 -0600
Subject: [PATCH 71/92] Version Packages (#18125)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.55.5
### Patch Changes
- fix: don't mark deriveds while an effect is updating
([#18124](https://github.com/sveltejs/svelte/pull/18124))
- fix: do not dispatch introstart event with animation of animate
directive ([#18122](https://github.com/sveltejs/svelte/pull/18122))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/common-candles-sneeze.md | 5 -----
.changeset/hip-flowers-give.md | 5 -----
packages/svelte/CHANGELOG.md | 8 ++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
5 files changed, 10 insertions(+), 12 deletions(-)
delete mode 100644 .changeset/common-candles-sneeze.md
delete mode 100644 .changeset/hip-flowers-give.md
diff --git a/.changeset/common-candles-sneeze.md b/.changeset/common-candles-sneeze.md
deleted file mode 100644
index a0ef7b610b..0000000000
--- a/.changeset/common-candles-sneeze.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: don't mark deriveds while an effect is updating
diff --git a/.changeset/hip-flowers-give.md b/.changeset/hip-flowers-give.md
deleted file mode 100644
index 77f4dd8892..0000000000
--- a/.changeset/hip-flowers-give.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: do not dispatch introstart event with animation of animate directive
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 7aa8f9818e..8e0f8a0916 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,13 @@
# svelte
+## 5.55.5
+
+### Patch Changes
+
+- fix: don't mark deriveds while an effect is updating ([#18124](https://github.com/sveltejs/svelte/pull/18124))
+
+- fix: do not dispatch introstart event with animation of animate directive ([#18122](https://github.com/sveltejs/svelte/pull/18122))
+
## 5.55.4
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 12aa895ecf..a4ae208913 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
- "version": "5.55.4",
+ "version": "5.55.5",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index cb8d3f76ab..04b0b0398a 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.55.4';
+export const VERSION = '5.55.5';
export const PUBLIC_VERSION = '5';
From bd29b9ef2ba2a140e7e7d8835177e5ad34c6bf18 Mon Sep 17 00:00:00 2001
From: Wesley <2011150255@email.szu.edu.cn>
Date: Thu, 30 Apr 2026 00:34:09 +0800
Subject: [PATCH 72/92] docs: document $state.snapshot toJSON behavior (#18154)
### 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.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [ ] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).
### Tests and linting
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
`$state.snapshot` already clones the value returned from `toJSON`, and
its `Snapshot` type reflects that return type. The `$state.snapshot`
docs now call out that behavior explicitly, including the generated
ambient types shown by editors.
Test plan: `pnpm check`; `pnpm lint`.
Fixes #18129
Co-authored-by: sakaenyeniceri5
---
documentation/docs/02-runes/02-$state.md | 2 ++
packages/svelte/src/ambient.d.ts | 2 ++
packages/svelte/types/index.d.ts | 2 ++
3 files changed, 6 insertions(+)
diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md
index d763b6578f..b90c71366a 100644
--- a/documentation/docs/02-runes/02-$state.md
+++ b/documentation/docs/02-runes/02-$state.md
@@ -167,6 +167,8 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps
This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`.
+If a value has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object.
+
## `$state.eager`
When state changes, it may not be reflected in the UI immediately if it is used by an `await` expression, because [updates are synchronized](await-expressions#Synchronized-updates).
diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts
index 159a568477..bbbc86c997 100644
--- a/packages/svelte/src/ambient.d.ts
+++ b/packages/svelte/src/ambient.d.ts
@@ -147,6 +147,8 @@ declare namespace $state {
*
* ```
*
+ * If `state` has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object.
+ *
* @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation}
*
* @param state The value to snapshot
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 019baf45dd..3f71d44177 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -3345,6 +3345,8 @@ declare namespace $state {
*
* ```
*
+ * If `state` has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object.
+ *
* @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation}
*
* @param state The value to snapshot
From ada3076967c34054a7450816f933f3c879f137fe Mon Sep 17 00:00:00 2001
From: 7nik
Date: Wed, 29 Apr 2026 20:45:55 +0300
Subject: [PATCH 73/92] fix: account for proxified instance when updating
`bind:this` (#18147)
Fixes #18145
I wonder why nulling happens after updating `bind:this` but not before,
which would fix the issue as well, though not as efficiently.
### 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/full-waves-tease.md | 5 +++++
.../client/dom/elements/bindings/this.js | 2 +-
.../bind-this-proxy-deep/Component.svelte | 5 +++++
.../samples/bind-this-proxy-deep/_config.js | 22 +++++++++++++++++++
.../samples/bind-this-proxy-deep/main.svelte | 16 ++++++++++++++
5 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 .changeset/full-waves-tease.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte
diff --git a/.changeset/full-waves-tease.md b/.changeset/full-waves-tease.md
new file mode 100644
index 0000000000..3915334bf7
--- /dev/null
+++ b/.changeset/full-waves-tease.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: account for proxified instance when updating `bind:this`
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js
index c39ca34062..52f0c213d3 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js
@@ -40,7 +40,7 @@ export function bind_this(element_or_component = {}, update, get_value, get_part
parts = get_parts?.() || [];
untrack(() => {
- if (element_or_component !== get_value(...parts)) {
+ if (!is_bound_this(get_value(...parts), element_or_component)) {
update(element_or_component, ...parts);
// If this is an effect rerun (cause: each block context changes), then nullify the binding at
// the previous position if it isn't already taken over by a different effect.
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte
new file mode 100644
index 0000000000..c43810b6bf
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte
@@ -0,0 +1,5 @@
+
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js
new file mode 100644
index 0000000000..2f9e60e017
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js
@@ -0,0 +1,22 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target, logs }) {
+ const btn = target.querySelector('button');
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ assert.deepEqual(logs, [
+ {},
+ { 0: { name: 'Row 0' } },
+ { 0: { name: 'Row 0' }, 1: { name: 'Row 1' } }
+ ]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte
new file mode 100644
index 0000000000..2e2a49b3f6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte
@@ -0,0 +1,16 @@
+
+
+ nums.push(nums.length)}>Add
+{#each rows as row (row.id)}
+
+{/each}
From 69b4c9f561a6dc14899889d815d555d398799a91 Mon Sep 17 00:00:00 2001
From: Dor Alagem
Date: Wed, 29 Apr 2026 20:47:12 +0300
Subject: [PATCH 74/92] fix: skip block comments in read_value to prevent
apostrophe parsing error (#18153)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #18134.
## Problem
`read_value()` in
`packages/svelte/src/compiler/phases/1-parse/read/style.js` has no logic
to skip CSS block comments (`/* ... */`). When the parser encounters an
apostrophe inside a comment, it sets `quote_mark = "'"` — treating it as
the start of a string literal — then never finds a matching closing
quote, and ultimately throws `unexpected_eof` at the end of the style
block.
Minimal repro:
```svelte
```
→ `Error: Unexpected end of input`
## Fix
Add a `/* ... */` skip path inside the `read_value` loop, mirroring the
same pattern already used in `allow_comment_or_whitespace`. When `/*` is
detected outside a string or url context, the parser advances past the
entire comment without adding its content to the value string.
---------
Co-authored-by: Dor Alagem
Co-authored-by: Rich Harris
---
.changeset/three-pears-build.md | 5 +++++
.../src/compiler/phases/1-parse/read/style.js | 15 +++++++++++++++
.../samples/comment-with-apostrophe/expected.css | 4 ++++
.../samples/comment-with-apostrophe/input.svelte | 7 +++++++
4 files changed, 31 insertions(+)
create mode 100644 .changeset/three-pears-build.md
create mode 100644 packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css
create mode 100644 packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte
diff --git a/.changeset/three-pears-build.md b/.changeset/three-pears-build.md
new file mode 100644
index 0000000000..8a638149a3
--- /dev/null
+++ b/.changeset/three-pears-build.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ignore comments when reading CSS values
diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js
index 8cb1d54d54..160e5da277 100644
--- a/packages/svelte/src/compiler/phases/1-parse/read/style.js
+++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js
@@ -524,6 +524,21 @@ function read_value(parser) {
in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) {
return value.trim();
+ } else if (
+ char === '/' &&
+ !in_url &&
+ !quote_mark &&
+ parser.template[parser.index + 1] === '*'
+ ) {
+ parser.index += 2;
+ while (parser.index < parser.template.length) {
+ if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') {
+ parser.index += 2;
+ break;
+ }
+ parser.index++;
+ }
+ continue;
}
value += char;
diff --git a/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css b/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css
new file mode 100644
index 0000000000..a196d53cc8
--- /dev/null
+++ b/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css
@@ -0,0 +1,4 @@
+
+ p.svelte-xyz {
+ padding: 0 /* it's a comment */ 1em;
+ }
diff --git a/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte b/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte
new file mode 100644
index 0000000000..0f9e0d2355
--- /dev/null
+++ b/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte
@@ -0,0 +1,7 @@
+red
+
+
From 4c96b469f809c2615bf1a634521bdbd1ca91fbcd Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:50:11 +0200
Subject: [PATCH 75/92] fix: allow `@debug` tags to reference awaited variables
(#18138)
Fixes #18137
---
.changeset/small-tools-walk.md | 5 +++
.../3-transform/client/visitors/DebugTag.js | 14 ++++++---
.../3-transform/server/visitors/DebugTag.js | 31 +++++++++++++------
.../async-debug-awaited-expression/_config.js | 18 +++++++++++
.../main.svelte | 4 +++
5 files changed, 58 insertions(+), 14 deletions(-)
create mode 100644 .changeset/small-tools-walk.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte
diff --git a/.changeset/small-tools-walk.md b/.changeset/small-tools-walk.md
new file mode 100644
index 0000000000..2b275368b3
--- /dev/null
+++ b/.changeset/small-tools-walk.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: allow `@debug` tags to reference awaited variables
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js
index ef9a070859..01a7e0e872 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js
@@ -8,6 +8,10 @@ import * as b from '#compiler/builders';
* @param {ComponentContext} context
*/
export function DebugTag(node, context) {
+ const blockers = node.identifiers
+ .map((identifier) => context.state.scope.get(identifier.name)?.blocker)
+ .filter((blocker) => blocker != null);
+
const object = b.object(
node.identifiers.map((identifier) => {
const visited = b.call('$.snapshot', /** @type {Expression} */ (context.visit(identifier)));
@@ -20,9 +24,11 @@ export function DebugTag(node, context) {
})
);
- const call = b.call('console.log', object);
+ const args = [b.thunk(b.block([b.stmt(b.call('console.log', object)), b.debugger]))];
- context.state.init.push(
- b.stmt(b.call('$.template_effect', b.thunk(b.block([b.stmt(call), b.debugger]))))
- );
+ if (blockers.length > 0) {
+ args.push(b.array([]), b.array([]), b.array(blockers));
+ }
+
+ context.state.init.push(b.stmt(b.call('$.template_effect', ...args)));
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js
index 31b53fd3eb..3c4af2fe04 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js
@@ -2,23 +2,34 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
+import { create_child_block } from './shared/utils.js';
/**
* @param {AST.DebugTag} node
* @param {ComponentContext} context
*/
export function DebugTag(node, context) {
+ const blockers = node.identifiers
+ .map((identifier) => context.state.scope.get(identifier.name)?.blocker)
+ .filter((blocker) => blocker != null);
+
context.state.template.push(
- b.stmt(
- b.call(
- 'console.log',
- b.object(
- node.identifiers.map((identifier) =>
- b.prop('init', identifier, /** @type {Expression} */ (context.visit(identifier)))
+ ...create_child_block(
+ [
+ b.stmt(
+ b.call(
+ 'console.log',
+ b.object(
+ node.identifiers.map((identifier) =>
+ b.prop('init', identifier, /** @type {Expression} */ (context.visit(identifier)))
+ )
+ )
)
- )
- )
- ),
- b.debugger
+ ),
+ b.debugger
+ ],
+ b.array(blockers),
+ false
+ )
);
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js
new file mode 100644
index 0000000000..304f65cd0a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js
@@ -0,0 +1,18 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+ mode: ['client', 'async-server'],
+
+ async test({ assert, logs }) {
+ await tick();
+
+ assert.deepEqual(logs, [{ data: 'works' }]);
+ },
+ test_ssr({ assert, logs }) {
+ assert.deepEqual(logs, [{ data: 'works' }]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte
new file mode 100644
index 0000000000..92b69df8fb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte
@@ -0,0 +1,4 @@
+
+ {@const data = await Promise.resolve("works")}
+ {@debug data}
+
\ No newline at end of file
From bc82a55647745db00d2c3e565e6b1f4125d6e391 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:57:10 +0200
Subject: [PATCH 76/92] fix: ensure scheduled batch is flushed if not obsolete
(#18131)
The logic of checking that the current batch is still the generated one
is flawed. If microtasks align the current batch can be a different
value even if the batch still need to be flushed. This therefore
switches the heuristic to what it actually should express: "has this
batch run already?"
Fixes #18126 (because the batch isn't running, it later runs into the
invariant)
---
.changeset/many-pandas-add.md | 5 +++++
.../src/internal/client/reactivity/batch.js | 2 +-
.../_config.js | 15 +++++++++++++++
.../main.svelte | 18 ++++++++++++++++++
4 files changed, 39 insertions(+), 1 deletion(-)
create mode 100644 .changeset/many-pandas-add.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte
diff --git a/.changeset/many-pandas-add.md b/.changeset/many-pandas-add.md
new file mode 100644
index 0000000000..85de7acb35
--- /dev/null
+++ b/.changeset/many-pandas-add.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure scheduled batch is flushed if not obsolete
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 82be1d1e8d..7adf3be00c 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -716,7 +716,7 @@ export class Batch {
if (!is_flushing_sync) {
queue_micro_task(() => {
- if (current_batch !== batch) {
+ if (!batches.has(batch) || batch.#pending.size > 0) {
// a flushSync happened in the meantime
return;
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js
new file mode 100644
index 0000000000..dea121c456
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js
@@ -0,0 +1,15 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+// Ensure that microtask timing doesn't influence whether or not a scheduled batch is flushed.
+// Timing can be such that the current_batch is reset before the scheduled flush runs, which
+// would cause the flush to skip without the fix.
+export default test({
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, '1 1');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte
new file mode 100644
index 0000000000..ebfbf4ca4e
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte
@@ -0,0 +1,18 @@
+
+
+{#if a}
+ {@const toShow = await a}
+ {toShow}
+ {b}
+{:else}
+ {
+ a = 1;
+ await 1;
+ await 1; // two microtasks needed to get timing right to reproduce the bug
+ b = 1;
+ }}>click
+{/if}
From 146cb5ea6c0fc6c49677a732c047b3ff497936a1 Mon Sep 17 00:00:00 2001
From: Eduardo Kurek <133408246+eduardo-kurek@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:10:19 -0300
Subject: [PATCH 77/92] fix: lazy props reactivity (#18146)
Fix #18132
This PR treat lazy fallbacks on `prop()` as derived. Now a default
function that uses a $state is recalculated whenever its dependents
changes. This change implies that this lazy functions cannot mutate a
state anymore (because it is derived), causing a
`state_unsafe_mutation`error. This implies on a breaking change, but
reasonable.
---
### New breaking change here
- **Who does this affect**: Everyone that has updated a $state on a
default lazy prop. Example:
```html
```
**Why make this breaking change**
This encourages people to not update states on a function that
fundamentaly, is readonly. When someone wants to use a default function
expecting that it should be tracked, its not likely that this function
will change some state. It is anti-pattern to change some state inside a
getter function.
But what if someone wants to do it, like in the code above?
The code above doesn't make sense before this PR, the old way to
calculate lazy functions is to execute it one time, and only one, so the
`callCount` variable will never change. But let's assume that someone
did it, how to migrate?
The migration in same example is easy, since the `callCount` is executed
only once, it will not be executed after the component is mounted. So
the `callCount` doesn't need to be a state, the `callCount` will be in a
valid state when the component is created. So here is the migrated code:
```html
```
As we can see, there is no reason for the variable `callCount` in this
example (before this PR), and if someone did it, it is more likely that
they used a constant instead:
```html
```
There is another example that causes the `state_unsafe_mutation` and how
to fix (this happened on the tests that i changed):
```html
```
Here, we can see that `log` variable is a state. Before this PR, as i
said, this function `fallbackExample` will be executed once. So the logs
will be computed when the component is mounted. So there is no reason to
make the `log` a state. The simplest way to fix this is to make it a
normal variable:
```html
```
But with this PR, the function might be recalculated at some point, and
the `log` with a state makes sense now, so how to migrate in this case?
As i said, changing a state inside a lazy prop function is not a good
practice, we can think in a way to invert this dependency, and change
the approach from push (imperative mutation) to pull (declarative
derivation).
If a developer really needs to track how many times a fallback is
executed or react to its changes, they should use a $derived or an
$effect that observes the same dependencies as the fallback, or simply
observe the property itself:
```html
```
### After all, how to migrate?
1. **If there is no state mutation inside the prop function, no need to
changes**;
2. **If there is a state mutation inside the prop function, but the
value muted is declared inside the same component:** remove the state
from it. Before this PR the method will be executed only once, and to
get the same result, you do not need the variable to be a state;
**Before**
```html
```
**After**
```html
```
3. **If there is a state mutation inside the prop function, and the
value is read in multiple places:** change your approach, use a effect
to detect the change on the prop, and apply your mutation inside the
effect;
Before:
```html
```
After
```html
```
### Severity (number of people affected x effort): Low
- **Affected Users:** Minimal. Mutating state inside a property
initializer is a rare edge case and considered an anti-pattern (because
its a side effect inside a getter). Most users use constants or pure
functions for fallbacks.
- **Migration Effort:** Low. As demonstrated in the examples above, the
fix usually involves either removing an unnecessary $state or moving the
side effect to its proper place, the $effect
### Conclusion
This PR encourages users to program in a better way. Forcing a clean
separation between data and their side effects. The developer can use
this new feature mainly in i18n services, providing better usability and
experience. Also, this PR makes the properties more predictable, since
the expected behavior is that it works reactively, eliminating this bug
for future developers.
Even though this PR adds a breaking change, it's easily solvable, and
the chance of any user facing this problem is low.
**Full example to test reactivity in props** (won't work on web, you can
get the PR and test localy to see it working):
https://svelte.dev/playground/a6608434d8c642179f0e2b72468c74d7?version=latest
*A unit test for this reactivity was created:
runtime-runes/props-default-value-reactivity*.
---------
Co-authored-by: Rich Harris
Co-authored-by: Rich Harris
---
.changeset/smooth-poems-tap.md | 5 +++++
.../src/internal/client/reactivity/props.js | 8 +++++++-
.../main.svelte | 2 +-
.../props-default-value-lazy/sub.svelte | 2 +-
.../props-default-value-reactivity/_config.js | 19 +++++++++++++++++++
.../main.svelte | 10 ++++++++++
.../props-default-value-reactivity/sub.svelte | 9 +++++++++
.../translations.svelte.js | 9 +++++++++
8 files changed, 61 insertions(+), 3 deletions(-)
create mode 100644 .changeset/smooth-poems-tap.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js
diff --git a/.changeset/smooth-poems-tap.md b/.changeset/smooth-poems-tap.md
new file mode 100644
index 0000000000..ac160656cf
--- /dev/null
+++ b/.changeset/smooth-poems-tap.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: re-run fallback props if dependencies update
diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js
index e208d3b6f6..5626639a84 100644
--- a/packages/svelte/src/internal/client/reactivity/props.js
+++ b/packages/svelte/src/internal/client/reactivity/props.js
@@ -1,4 +1,4 @@
-/** @import { Effect, Source } from './types.js' */
+/** @import { Derived, Effect, Source } from './types.js' */
import { DEV } from 'esm-env';
import {
PROPS_IS_BINDABLE,
@@ -283,8 +283,14 @@ export function prop(props, key, flags, fallback) {
var fallback_value = /** @type {V} */ (fallback);
var fallback_dirty = true;
+ var fallback_signal = /** @type {Derived | undefined} */ (undefined);
var get_fallback = () => {
+ if (lazy && runes) {
+ fallback_signal ??= derived(/** @type {() => V} */ (fallback));
+ return get(fallback_signal);
+ }
+
if (fallback_dirty) {
fallback_dirty = false;
diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte
index fe2ac37bd3..f6437d6589 100644
--- a/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte
@@ -1,5 +1,5 @@
+
+
+
+ set_translation('Hola')}>
+ Change Language
+
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte
new file mode 100644
index 0000000000..b3cd3fae36
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte
@@ -0,0 +1,9 @@
+
+
+greeting: {p0}
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js
new file mode 100644
index 0000000000..4aa4dc9999
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js
@@ -0,0 +1,9 @@
+let greeting = $state('Hello');
+
+export function get_translation() {
+ return greeting;
+}
+
+export function set_translation(value) {
+ greeting = value;
+}
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 78/92] 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 79/92] 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 @@
+
+
+ count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}
+ queued.shift()?.()}>resolve
+
+{#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 @@
+
+
+ { a++; b++; }}>increment
+ queued.shift()?.()}>resolve
+{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 @@
+
+
+ count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}
+ queued.shift()?.()}>shift
+ queued.pop()?.()}>pop
+
+{#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,
+ `
+ count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 0
+ unrelated++
+ resolve
+ `
+ );
+
+ // 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,
+ `
+ count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 1
+ unrelated++
+ resolve
+ `
+ );
+
+ // 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,
+ `
+ count 1 | count_mirror 1 | count_mirror_d 2 | unrelated 1
+ unrelated++
+ resolve
+ `
+ );
+ }
+});
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 @@
+
+
+ count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)} | count_mirror_d {count_mirror_d} | unrelated {unrelated}
+ unrelated++}>unrelated++
+ queued.shift()?.()}>resolve
+
+{#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 80/92] 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,
+ `clicks: 0 - 0 - 0 shift true - true
`
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `clicks: 1 - 1 - 1 shift 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 @@
+
+
+ count += 1}>
+ clicks: {count} - {delayedCount} - {derivedCount}
+
+ resolvers.shift()?.()}>shift
+
+{$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 81/92] 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, 'clear ');
+ 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}
+
+ reloadData()}>clear
From e65025be58c2e6ed67caf982e8c49a078e521496 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 30 Apr 2026 16:58:06 -0400
Subject: [PATCH 82/92] 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('0 ');
// 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('1 ');
@@ -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 83/92] 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,
+ `increment pop 0 0 0
`
+ );
+
+ pop.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `increment pop 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 @@
+
+
+ {
+ if (count === 0) other++;
+ count++;
+}}>increment
+ queue.pop()?.()}>pop
+
+{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 84/92] 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 @@
+
\ 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
+
+
+
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 85/92] 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 86/92] 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 @@
+
+
+update
+
+{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 87/92] 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 88/92] 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 89/92] 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 @@
+
+
+ count += 1}>increment
+ deferreds.shift()?.resolve()}>resolve
+
+
+ {#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 90/92] 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 91/92] 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}
+
+
+ {a++;}}>a++
+ deferred.shift()?.()}>shift
+ deferred[2]()}>middle
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,
+ `increment hide pop 1`
+ );
+
+ pop.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `increment hide pop 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 @@
+
+
+ count += 1}>increment
+ show = false}>hide
+
+ queue.pop()?.()}>pop
+
+{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,
+ `3 shift 1 = 1
fizz: true
buzz: true
`
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `3 shift 1 = 1
fizz: true
buzz: true
`
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `3 shift 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 @@
+
+
+
+ {$state.eager(n)}
+
+
+ queue.shift()?.()}>shift
+
+{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++;c++}}>a / c
+ {b+=2;d++}}>b / d
+ queue1.pop()?.()}>pop 1
+ queue2.shift()?.()}>shift 2
+
+{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++;c++}}>a / c
+ {b+=2;d++}}>b / d
+ queue1.shift()?.()}>shift 1
+ queue1.pop()?.()}>pop 1
+ queue2.shift()?.()}>shift 2
+
+{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 92/92] 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,