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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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()}