From 41642b70ee471dfe024dfface4c0c9939fa60d3b Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 9 Jul 2026 15:06:08 +0200 Subject: [PATCH 1/7] fix: avoid declaration tag warning in event handlers (#18500) Closes #18493 Co-authored-by: justjavac --- .changeset/dull-oranges-fry.md | 5 +++++ .../compiler/phases/2-analyze/visitors/shared/function.js | 4 +++- .../declaration-tag-state-referenced-locally/input.svelte | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/dull-oranges-fry.md diff --git a/.changeset/dull-oranges-fry.md b/.changeset/dull-oranges-fry.md new file mode 100644 index 0000000000..0efcc6fa2d --- /dev/null +++ b/.changeset/dull-oranges-fry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid declaration tag warning in event handlers diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js index 7bdb2243f2..6b3d227eca 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js @@ -18,7 +18,9 @@ export function visit_function(node, context) { context.next({ ...context.state, - function_depth: context.state.function_depth + 1, + // we generally want to use scope.function_depth unless we specifically increased + // that in state.function_depth (e.g. a derived) + function_depth: Math.max(context.state.scope.function_depth, context.state.function_depth) + 1, expression: null }); } diff --git a/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte index 90cc208e72..f422be9622 100644 --- a/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte +++ b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte @@ -7,3 +7,7 @@ {let e = $state(0), f = e} {a}{b}{c}{d}{e}{f} + + \ No newline at end of file From 5edd8b0602c81494ebdad7f1a5d8077f869c2097 Mon Sep 17 00:00:00 2001 From: Ivan <273312799+Socialpranker@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:59:23 +0200 Subject: [PATCH 2/7] fix: chain preprocessor sourcemaps with an empty sources[0] (#18518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #18491 — the compiler discards an upstream plugin's sourcemap when it's generated without a `source` option (e.g. `new MagicString(code).generateMap()`), producing wrong devtools/stack-trace positions. ### Root cause `compile()` already supports composing an incoming sourcemap into its output via `options.sourcemap` (`merge_with_preprocessor_map` → `apply_preprocessor_sourcemap` → `combine_sourcemaps` in `packages/svelte/src/compiler/utils/mapped_code.js`). This is the same mechanism `preprocess()` uses internally, and it's the documented contract for tools that transform a `.svelte` file before compiling it (see `CompileOptions.sourcemap`'s doc comment). `combine_sourcemaps` composes maps by matching `sourcefile === filename` (the basename of the file being compiled). A sourcemap produced by `new MagicString(code).generateMap()` **without** a `source` option — which is exactly what the reporter's Vite plugin does — has `sources: ['']`. That empty string never equals `filename`, so `remapping()` treats the node as a leaf (the "original" file) instead of a branch to keep chaining through, and the whole incoming map is silently dropped. The result: every position that flows through that segment resolves to `{ source: null, line: null, column: null }`, which is what produces the wrong/missing devtools mapping described in the issue. Vite itself already has to handle this exact ambiguity: in `pluginContainer.ts`'s `_getCombinedSourcemap`, an empty `sources[0]` from a MagicString-based transform is patched to refer to the file being transformed before Vite uses it internally. This PR applies the same normalization on svelte's side, so the contract holds regardless of whether the caller happens to pass a `source` option to `generateMap()`. ### Fix In `apply_preprocessor_sourcemap`, normalize an incoming map's `sources: ['']` (or `[null]`/`[undefined]`) to `[filename]` before calling `combine_sourcemaps`, so the chain-matching step can actually find it. --- .changeset/tame-donkeys-jump.md | 5 ++++ .../svelte/src/compiler/utils/mapped_code.js | 7 +++++ .../samples/sourcemap-empty-source/_config.js | 29 +++++++++++++++++++ .../sourcemap-empty-source/input.svelte | 6 ++++ 4 files changed, 47 insertions(+) create mode 100644 .changeset/tame-donkeys-jump.md create mode 100644 packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/_config.js create mode 100644 packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/input.svelte diff --git a/.changeset/tame-donkeys-jump.md b/.changeset/tame-donkeys-jump.md new file mode 100644 index 0000000000..aae38910db --- /dev/null +++ b/.changeset/tame-donkeys-jump.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: chain preprocessor sourcemaps with an empty `sources[0]` instead of dropping them diff --git a/packages/svelte/src/compiler/utils/mapped_code.js b/packages/svelte/src/compiler/utils/mapped_code.js index 7686ba59c6..635743c0c4 100644 --- a/packages/svelte/src/compiler/utils/mapped_code.js +++ b/packages/svelte/src/compiler/utils/mapped_code.js @@ -311,6 +311,13 @@ function apply_preprocessor_sourcemap(filename, svelte_map, preprocessor_map_inp typeof preprocessor_map_input === 'string' ? JSON.parse(preprocessor_map_input) : preprocessor_map_input; + // A preprocessor map with a missing/empty `sources[0]` (e.g. from a MagicString transform + // created without a `source` option) can't be matched against `filename` during combination, + // which silently drops the chain instead of erroring. Normalize it to `filename` first, the + // same way Vite treats an empty `sources[0]` as referring to the file being transformed. + if (preprocessor_map.sources?.length === 1 && !preprocessor_map.sources[0]) { + preprocessor_map.sources = [filename]; + } const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]); // Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class, // we just tack on the extra properties. diff --git a/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/_config.js b/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/_config.js new file mode 100644 index 0000000000..43a2a4c16a --- /dev/null +++ b/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/_config.js @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import MagicString from 'magic-string'; +import { test } from '../../test'; + +// Simulates a bundler plugin (e.g. a Vite plugin using `magic-string`) that transforms +// the Svelte source *before* it reaches `compile()`, and hands its own sourcemap to +// `compileOptions.sourcemap` — the documented way to let svelte chain an upstream map +// into its own output map. Crucially, the upstream map is generated *without* a `source` +// option, exactly like `new MagicString(code).generateMap()` — this yields a sourcemap +// whose `sources` is `['']`, which previously broke the chain entirely (see #18491) +// instead of being treated as "this file", causing every mapping through it to resolve +// to `{ source: null, line: null, column: null }`. +const input = fs.readFileSync(new URL('./input.svelte', import.meta.url), 'utf-8'); +const src = new MagicString(input); +src.overwrite( + src.original.indexOf('count * 2'), + src.original.indexOf('count * 2') + 'count * 2'.length, + 'count * 2', + { + storeName: false + } +); + +export default test({ + compileOptions: { + sourcemap: src.generateMap({ hires: true }) + }, + client: [{ str: 'let doubled' }] +}); diff --git a/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/input.svelte b/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/input.svelte new file mode 100644 index 0000000000..b4e0b50bdb --- /dev/null +++ b/packages/svelte/tests/sourcemaps/samples/sourcemap-empty-source/input.svelte @@ -0,0 +1,6 @@ + + + From c2b7642263ec1f9d89f5d027d68f272742c449a7 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:35 +0200 Subject: [PATCH 3/7] chore: add benchmarks (#18523) Add two kairo variants to better measure of `#traverse` / block effects perf --- .../tests/kairo_broad_block.bench.js | 47 ++++++++++++++++++ .../tests/kairo_deep_block.bench.js | 48 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js create mode 100644 benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js new file mode 100644 index 0000000000..e8451ec3fa --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js @@ -0,0 +1,47 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; +import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js'; + +// Like `kairo_broad`, but each derived is also read by a block effect, as +// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better. +export default () => { + let head = $.state(0); + let last = head; + let counter = 0; + + const destroy = $.effect_root(() => { + for (let i = 0; i < 50; i++) { + let current = $.derived(() => { + return $.get(head) + i; + }); + let current2 = $.derived(() => { + return $.get(current) + 1; + }); + block(() => { + $.get(current2); + }); + $.render_effect(() => { + $.get(current2); + counter++; + }); + last = current2; + } + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + counter = 0; + for (let i = 0; i < 50; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(last), i + 50); + } + assert.equal(counter, 50 * 50); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js new file mode 100644 index 0000000000..a8d9fa8c35 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; +import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js'; + +let len = 50; +const iter = 50; + +// Like `kairo_deep`, but the derived chain is also read by a block effect, as +// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better. +export default () => { + let head = $.state(0); + let current = head; + for (let i = 0; i < len; i++) { + let c = current; + current = $.derived(() => { + return $.get(c) + 1; + }); + } + let counter = 0; + + const destroy = $.effect_root(() => { + block(() => { + $.get(current); + }); + + $.render_effect(() => { + $.get(current); + counter++; + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + counter = 0; + for (let i = 0; i < iter; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(current), len + i); + } + assert.equal(counter, iter); + } + }; +}; From bfbb026f2f7db6ced0d86ba0feb40587c0e8f598 Mon Sep 17 00:00:00 2001 From: JY Wey <34165386+JaiWey@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:42:01 +1200 Subject: [PATCH 4/7] fix: skip unnecessary derived effect in earlier batch (#18525) fixes #18438 Currently the `mark` method inside `#merge` for `earlier_batch` will schedule effect for undirty derived. Add the condition for derived to prevent unnecessary effect be scheduled. ``` if ((flags & DERIVED) !== 0) { mark(/** @type {Derived} */ (reaction)); } ``` --- .changeset/curly-wasps-hide.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 6 ++++ .../samples/async-batch-derived/_config.js | 28 ++++++++++++++++++ .../samples/async-batch-derived/main.svelte | 29 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 .changeset/curly-wasps-hide.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte diff --git a/.changeset/curly-wasps-hide.md b/.changeset/curly-wasps-hide.md new file mode 100644 index 0000000000..7e55d77ba0 --- /dev/null +++ b/.changeset/curly-wasps-hide.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: skip unnecessary derived effect in earlier batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 7d14b80519..5becae2dc9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -530,6 +530,12 @@ export class Batch { const mark = (value) => { var reactions = value.reactions; if (reactions === null) return; + // skip if value is derived and is neither dirty nor maybe dirty. transitive + // deriveds (a derived depending on another derived) are only MAYBE_DIRTY, so + // we must continue traversing them to reach the effects that depend on them + if ((value.f & DERIVED) !== 0 && (value.f & (DIRTY | MAYBE_DIRTY)) === 0) { + return; + } for (const reaction of reactions) { var flags = reaction.f; diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js new file mode 100644 index 0000000000..d5286d9d95 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

Loading...

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

Loading...

` + ); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte new file mode 100644 index 0000000000..af7f0f468c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte @@ -0,0 +1,29 @@ + + + + +{#snippet defaultPending()} +

Loading...

+{/snippet} + +{#if count > 0} + + {await push(count)} {count} {other} + +{/if} From 08a9e9e7e43453e2146357dd72d44cca1e41e29e Mon Sep 17 00:00:00 2001 From: Rabindra Kumar Meher Date: Fri, 10 Jul 2026 19:29:34 +0530 Subject: [PATCH 5/7] fix: prevent derived connection leak in untracked contexts (#18517) Fixes #18501. Problem is that is_updating_effect was also set to true for branch/root effects which are not reactive --------- Co-authored-by: Simon Holthausen --- packages/svelte/src/internal/client/runtime.js | 5 ++++- packages/svelte/tests/signals/test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 227203523e..188d16a820 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -61,6 +61,9 @@ 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'; +/** + * True if updating in an effect context that is reactive (i.e. not branch/root effects) + */ let is_updating_effect = false; export let is_destroying_effect = false; @@ -444,7 +447,7 @@ export function update_effect(effect) { var was_updating_effect = is_updating_effect; active_effect = effect; - is_updating_effect = true; + is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts if (DEV) { var previous_component_fn = dev_current_component_function; diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 927ce2e665..07f79bd395 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1503,4 +1503,22 @@ describe('signals', () => { assert.deepEqual(log, ['inner destroyed', 'inner destroyed']); }; }); + + test('derived read in an untracked context should not leak in deps reactions', () => { + return () => { + let s = state('hello'); + let a = derived(() => $.get(s)); + let b = derived(() => $.get(a)); + + let destroy = effect_root(() => { + $.get(b); + }); + + destroy(); + + // a was spuriously added to s.reactions via is_updating_effect + // even though the entire derived chain was read in an untracked context + assert.equal(s.reactions, null); + }; + }); }); From 8e4f26552c7dd40cc056a28357f6171542d2549d Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:01:20 +0200 Subject: [PATCH 6/7] chore: changeset (#18527) for #18517 --- .changeset/common-ways-deny.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/common-ways-deny.md diff --git a/.changeset/common-ways-deny.md b/.changeset/common-ways-deny.md new file mode 100644 index 0000000000..75e9375965 --- /dev/null +++ b/.changeset/common-ways-deny.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't (re)connect deriveds when read inside branch/root effects From b4d1583ae20f3869a88a731d9a265c546c099f66 Mon Sep 17 00:00:00 2001 From: Thribhuvan Date: Fri, 10 Jul 2026 19:31:48 +0530 Subject: [PATCH 7/7] fix: transform computed keys in keyed each block destructuring patterns (#18521) fixes #18519 --- .changeset/shiny-keys-dance.md | 5 ++++ .../3-transform/client/visitors/EachBlock.js | 5 +++- .../Child.svelte | 7 +++++ .../_config.js | 29 +++++++++++++++++++ .../main.svelte | 12 ++++++++ 5 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/shiny-keys-dance.md create mode 100644 packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/main.svelte diff --git a/.changeset/shiny-keys-dance.md b/.changeset/shiny-keys-dance.md new file mode 100644 index 0000000000..20cf754212 --- /dev/null +++ b/.changeset/shiny-keys-dance.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: transform computed keys in keyed `{#each}` destructuring patterns diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index a1371b516a..b33eddd461 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -294,7 +294,10 @@ export function EachBlock(node, context) { let key_function = b.id('$.index'); if (node.metadata.keyed) { - const pattern = /** @type {Pattern} */ (node.context); // can only be keyed when a context is provided + // can only be keyed when a context is provided + const pattern = /** @type {Pattern} */ ( + context.visit(/** @type {Pattern} */ (node.context), key_state) + ); const expression = /** @type {Expression} */ ( context.visit(/** @type {Expression} */ (node.key), key_state) ); diff --git a/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/Child.svelte b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/Child.svelte new file mode 100644 index 0000000000..35544d0a55 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/Child.svelte @@ -0,0 +1,7 @@ + + +{#each options as { [labelKey]: label, [valueKey]: value } (value)} +

{label}: {value}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/_config.js b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/_config.js new file mode 100644 index 0000000000..8a8789d712 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/_config.js @@ -0,0 +1,29 @@ +import { flushSync } from 'svelte'; +import { ok, test } from '../../test'; + +// https://github.com/sveltejs/svelte/issues/18519 +export default test({ + html: ` + +

1: a1

+

2: a2

+

3: a3

+ `, + + test({ assert, target }) { + const btn = target.querySelector('button'); + ok(btn); + + flushSync(() => btn.click()); + + assert.htmlEqual( + target.innerHTML, + ` + +

3: a3

+

2: a2

+

1: a1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/main.svelte new file mode 100644 index 0000000000..6885a6918e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-keyed-computed-destructuring-key/main.svelte @@ -0,0 +1,12 @@ + + + +