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, + ` + + +

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 @@ + + + + + + + {#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, '

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, + ` + + + + 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 @@ + + + + + + +{#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, - ` - - -

pending=6 values.length=1 values=[1]

-
not keyed: -
1
-
-
keyed: -
1
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=4 values.length=2 values=[1,2]

-
not keyed: -
1
-
2
-
-
keyed: -
1
-
2
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=2 values.length=3 values=[1,2,3]

-
not keyed: -
1
-
2
-
3
-
-
keyed: -
1
-
2
-
3
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=0 values.length=4 values=[1,2,3,4]

-
not keyed: -
1
-
2
-
3
-
4
-
-
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, `

hello

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

hello

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

hello

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

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, `

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 @@ + + + + + +{#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, + ' 0' + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 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 @@ + + + + + +{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, + ` + + + +

pending=6 values.length=1 values=[1]

+
not keyed: +
1
+
+
keyed: +
1
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=4 values.length=2 values=[1,2]

+
not keyed: +
1
+
2
+
+
keyed: +
1
+
2
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=2 values.length=3 values=[1,2,3]

+
not keyed: +
1
+
2
+
3
+
+
keyed: +
1
+
2
+
3
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=0 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
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, + ` + + + +

pending=8 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
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, + ` + + + +

pending=6 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
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, + ` + + + +

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); } - + +

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 + +``` + +```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 - ``` @@ -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} + + + + + 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_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + 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 + + + + + + ` + ); + + 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 + + + + + + ` + ); + } + } +}); 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} + + + + + 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_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // first a resolved, still pending: [b, a, b] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second b resolved, still pending: [b, a] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + 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 + + + + + + ` + ); + + shift.click(); // all resolved + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 2 | b 2 | c 1 | d 1 + + + + + + ` + ); + } +}); 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} + + + + + 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_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // first a resolved, still pending: [b, a, b] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second b resolved, still pending: [b, a] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + 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 + + + + + + ` + ); + + shift.click(); // queue empty + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 2 | b 2 | c 1 | d 1 + + + + + + ` + ); + } +}); 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} + + + + + 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 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + // how it's on main + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 1 | d 1 + + + + + ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + + // 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 + // + // + // + // + // ` + // ); + + // shift.click(); + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 1 | b 2 | c 0 | d 2 + // + // + // + // + // ` + // ); + + // shift.click(); + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 1 | b 2 | c 1 | d 3 + // + // + // + // + // ` + // ); + } +}); 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} + + + + \ 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 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + // 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 + + + + + ` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 1 | d 1 + + + + + ` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + } +}); 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} + + + + \ 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 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + 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 + + + + + ` + ); + + // how it's on main + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 0 | d 2 + + + + + ` + ); + + shift.click(); // obsolete second batch promise (already rejected) + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 0 | d 2 + + + + + ` + ); + + shift.click(); // first batch resolves + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + + // 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 + // + // + // + // + // ` + // ); + + // shift.click(); // obsolete second batch promise (already rejected) + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 0 | b 0 | c 0 | d 0 + // + // + // + // + // ` + // ); + + // 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 + // + // + // + // + // ` + // ); + } +}); 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} + + + + \ 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}

+ + + + + + 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}

+ + + + + + + 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}

+ + + + + + + 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, + ` + + + + ` // 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, + ` + + + + 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 @@ + + + + + + + + +{#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, + ` + + + +
+ ` // if this shows world world "world" world world world "world" - then this would also be ok + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 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 @@ + + + + + + + + +{#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, + ` + + + +
+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
+ ` // if this shows world world "world" world world world "world" - then this would also be ok + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 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 @@ + + + + + + + +{#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 = ` + + + + + + + + + +
+ `; + + const final = ` + + + + + + + + + + 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 @@ + + + + + + + + + + + + +{#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, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + 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 @@ + + + + + + + + +{#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, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + 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 @@ + + + + + + + + +{#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, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + 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 @@ + + + + + + + + +{#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, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + 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 @@ + + + + + + + + +{#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, + ` + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + 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 @@ + + + + + + + + +{#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 + + @@ -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()} @@ -169,12 +225,54 @@ As an authoring convenience, snippets declared directly _inside_ a component imp
fruit
``` +```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 + + ``` @@ -187,6 +285,7 @@ Any content inside the component tags that is _not_ a snippet declaration implic ``` + > [!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

+ @@ -165,7 +171,17 @@ Inputs that work together can use `bind:group` ([demo](/playground/untitled#H4sI + +

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({ - ` // 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({
- ` // 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({
- ` // 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)}

+ + + + + 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?.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` `); + + toggle?.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 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 @@ + + + + + +{#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, + ` + + + 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 @@ + + + + + + +{#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, + ` + + + ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + search search search search + + + ` + ); + } +}); 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} + + + 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 + + + + + + + {#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, ` 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, ` 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 @@ + + + + + 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 @@ + + + + +{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)} + + + 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)} + + 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 @@ + + + +{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, + ` + + + 1 +

pending: 1

+

loading...

+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 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 @@ + + + + + +{$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 @@ + + + + +{#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 `