From a9d8439ad1eecde5c4a91dca1eb1f034abfe7d81 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:02:04 +0100 Subject: [PATCH 01/32] fix: reschedule new effects in prior batches (#18021) If a batch creates a new branch (e.g. through an if block becoming true) the previous batches so far do not know about the new effects created through that. This can lead to stale values being shown. We therefore schedule those new effects on prior batches if they are touched by a `current` value of that batch Fixes #17099 extracted from #17971 --- .changeset/yellow-hairs-laugh.md | 5 +++ .../src/internal/client/reactivity/batch.js | 32 +++++++++++++++++++ .../src/internal/client/reactivity/effects.js | 4 ++- .../async-state-new-branch-1/_config.js | 13 ++++++-- .../async-state-new-branch-2/_config.js | 9 ++++-- .../async-state-new-branch-3/_config.js | 11 +++++-- .../async-state-new-branch-3/main.svelte | 1 - .../async-state-new-branch-fork-2/_config.js | 3 +- .../async-state-new-branch-fork-5/_config.js | 1 - .../samples/async-state-new-branch/_config.js | 1 - 10 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 .changeset/yellow-hairs-laugh.md diff --git a/.changeset/yellow-hairs-laugh.md b/.changeset/yellow-hairs-laugh.md new file mode 100644 index 0000000000..1c1ede8dc6 --- /dev/null +++ b/.changeset/yellow-hairs-laugh.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reschedule new effects in prior batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3b10d6ebe6..d53d824d03 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -145,6 +145,12 @@ export class Batch { */ #roots = []; + /** + * Effects created while this batch was active. + * @type {Effect[]} + */ + #new_effects = []; + /** * Deferred effects (which run after async work has completed) that are DIRTY * @type {Set} @@ -472,6 +478,13 @@ export class Batch { batches.delete(this); } + /** + * @param {Effect} effect + */ + register_created_effect(effect) { + this.#new_effects.push(effect); + } + #commit() { // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly @@ -525,6 +538,25 @@ export class Batch { mark_effects(source, others, marked, checked); } + checked = new Map(); + var current_unequal = [...batch.current.keys()].filter((c) => + this.current.has(c) ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c : true + ); + + for (const effect of this.#new_effects) { + if ( + (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && + depends_on(effect, current_unequal, checked) + ) { + if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { + set_signal_status(effect, DIRTY); + batch.schedule(effect); + } else { + batch.#dirty_effects.add(effect); + } + } + } + // Only apply and traverse when we know we triggered async work with marking the effects if (batch.#roots.length > 0) { batch.apply(); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 54c8a17d79..ea8a4b645e 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -42,7 +42,7 @@ import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; -import { Batch, collected_effects } from './batch.js'; +import { Batch, collected_effects, current_batch } from './batch.js'; import { flatten, increment_pending } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; @@ -120,6 +120,8 @@ function create_effect(type, fn) { effect.component_function = dev_current_component_function; } + current_batch?.register_created_effect(effect); + /** @type {Effect | null} */ var e = effect; diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js index 0af275009c..dee8af2446 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target, logs }) { const [x, y, resolve] = target.querySelectorAll('button'); @@ -17,12 +16,20 @@ export default test({ - ` // if this shows world world - that would also be ok + world + ` // if this does not show world - that would also be ok ); resolve.click(); await tick(); - assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); + assert.deepEqual(logs, [ + 'universe', + 'world', + '$effect: world', + '$effect: universe', + '$effect: universe' + ]); + // assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js index 035616dfb6..d99f0df731 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, resolve] = target.querySelectorAll('button'); @@ -18,7 +17,13 @@ export default test({
- ` // if this shows world world "world" world world world "world" - then this would also be ok + world + "world" + world + world + world + "world" + ` // if this does not show world "world" world world world "world" - then this would also be ok ); resolve.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js index a2d615b6e5..eb4485e8a6 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, resolve] = target.querySelectorAll('button'); @@ -30,9 +29,17 @@ export default test({
- ` // if this shows world world "world" world world world "world" - then this would also be ok + world + "world" + world + world + world + "world" + ` // if this does not show world "world" world world world "world" - then this would also be ok ); + resolve.click(); + await tick(); resolve.click(); await tick(); assert.htmlEqual( diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte index b02ab20995..c8a4ca587f 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte @@ -31,4 +31,3 @@ {#if y > 0} {/if} - \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js index a712e70630..74df968d82 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, shift, pop, commit] = target.querySelectorAll('button'); @@ -43,6 +42,8 @@ export default test({ await tick(); shift.click(); await tick(); + shift.click(); // would be ok to not need this one + await tick(); assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js index 9221a96c2e..e8f16ade3c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, resolve, commit] = target.querySelectorAll('button'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js index f2091eb6ab..f4b6cc777b 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // this fails on main, too; skip for now async test({ assert, target, logs }) { const [x, y, resolve] = target.querySelectorAll('button'); From 957f2755faaeeecb6774d2c57ec4b0d7b622453e Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Sat, 28 Mar 2026 17:30:48 +0100 Subject: [PATCH 02/32] fix: cleanup `superTypeParameters` in `ClassDeclarations`/`ClassExpression` (#18015) Closes #18012 We were removing `superTypeArguments` but the AST showed `superTypeParameters`. Luckily, I was able to pinpoint `esrap@2.2.4` as the cause... I've only bumped `esrap` and that made the test fail (so that I could fix it) And of course it was that...there's literally a commit that explicitly print them lol https://github.com/sveltejs/esrap/commit/f9137c41016ed86dbfe0d82e88ce54a5c9ab47ad --- .changeset/lucky-animals-take.md | 5 +++++ packages/svelte/package.json | 2 +- .../phases/1-parse/remove_typescript_nodes.js | 2 ++ .../runtime-runes/samples/typescript/main.svelte | 5 +++++ pnpm-lock.yaml | 11 ++++++----- 5 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 .changeset/lucky-animals-take.md diff --git a/.changeset/lucky-animals-take.md b/.changeset/lucky-animals-take.md new file mode 100644 index 0000000000..b249fa9f78 --- /dev/null +++ b/.changeset/lucky-animals-take.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: cleanup `superTypeParameters` in `ClassDeclarations`/`ClassExpression` diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 7b35924e15..25fae9f262 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -178,7 +178,7 @@ "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", diff --git a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js index 0835c5fc79..438d6fe48f 100644 --- a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js +++ b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js @@ -138,11 +138,13 @@ const visitors = { delete node.abstract; delete node.implements; delete node.superTypeArguments; + delete node.superTypeParameters; return context.next(); }, ClassExpression(node, context) { delete node.implements; delete node.superTypeArguments; + delete node.superTypeParameters; return context.next(); }, MethodDefinition(node, context) { diff --git a/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte b/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte index 4fc7c4ec38..0286f2c0fd 100644 --- a/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/typescript/main.svelte @@ -27,6 +27,11 @@ abstract x(): void; y() {} } + class Subclass extends Foo { + constructor(value: string) { + super(value); + } + } declare const declared_const: number; declare function declared_fn(): void; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa423e4923..2987749c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,8 +102,8 @@ importers: specifier: ^1.2.1 version: 1.2.1 esrap: - specifier: ^2.2.2 - version: 2.2.2 + specifier: ^2.2.4 + version: 2.2.4 is-reference: specifier: ^3.0.3 version: 3.0.3 @@ -1418,8 +1418,8 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - esrap@2.2.2: - resolution: {integrity: sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==} + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -3794,9 +3794,10 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.2: + esrap@2.2.4: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + '@typescript-eslint/types': 8.56.0 esrecurse@4.3.0: dependencies: From 04eadbc8a9ea905878fec90e61b5a62a2f9a4045 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:56:27 +0200 Subject: [PATCH 03/32] fix: correctly handle bindings on the server (#18009) `item.subsume(item)` did nothing, honestly kinda weird how this only turned up now Fixes #17981 --- .changeset/eight-trees-occur.md | 5 +++++ packages/svelte/src/internal/server/renderer.js | 10 +++++++--- .../samples/async-hydration-binding/Async.svelte | 7 +++++++ .../samples/async-hydration-binding/Binding.svelte | 7 +++++++ .../samples/async-hydration-binding/Bound.svelte | 0 .../samples/async-hydration-binding/_config.js | 10 ++++++++++ .../samples/async-hydration-binding/main.svelte | 7 +++++++ 7 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 .changeset/eight-trees-occur.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte diff --git a/.changeset/eight-trees-occur.md b/.changeset/eight-trees-occur.md new file mode 100644 index 0000000000..cc200b39ba --- /dev/null +++ b/.changeset/eight-trees-occur.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly handle bindings on the server diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index da65811eca..4bcb45512e 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -468,10 +468,14 @@ export class Renderer { } this.local = other.local; - this.#out = other.#out.map((item) => { - if (item instanceof Renderer) { - item.subsume(item); + this.#out = other.#out.map((item, i) => { + const current = this.#out[i]; + + if (current instanceof Renderer && item instanceof Renderer) { + current.subsume(item); + return current; } + return item; }); this.promise = other.promise; diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte new file mode 100644 index 0000000000..6e7dbc82bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Async.svelte @@ -0,0 +1,7 @@ + + +
+ {data} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte new file mode 100644 index 0000000000..3deba21acc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Binding.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/Bound.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js new file mode 100644 index 0000000000..d4d36b007f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/_config.js @@ -0,0 +1,10 @@ +import { test } from '../../test'; + +// Tests that renderer.subsume (which is used when bindings are present) works correctly +export default test({ + mode: ['hydrate'], + html: '
test
', + async test({ assert, warnings }) { + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte new file mode 100644 index 0000000000..a38806be7c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hydration-binding/main.svelte @@ -0,0 +1,7 @@ + + + + From 4879f9da999c2919a60e5bd60afd89042adc7ea7 Mon Sep 17 00:00:00 2001 From: Rohit Nair P Date: Mon, 30 Mar 2026 01:34:18 +0530 Subject: [PATCH 04/32] fix: improve duplicate module import error message (#18016) ## Description **While investigating the compiler analysis phase for errors or missing lines, I located an explicit `TODO fix the message here` comment in the source code (`packages/svelte/src/compiler/phases/2-analyze/visitors/shared/utils.js`).** ### Triggering Code ```svelte ``` ## The Bug When a component author creates a local `let` declaration in the instance ` + + + +{#if true} + {@const m1 = message} + {@const m2 = (() => m1)()} + +

{m1}

+

{m2}

+{/if} From 99b1467ba4d48de9eb85beec4dbc329b98b94d71 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 1 Apr 2026 11:58:18 -0400 Subject: [PATCH 12/32] chore: generate CPU profiles when running benchmarks (#18043) This causes `pnpm bench` and `pnpm bench:compare` to generate `.cpuprofile` files for each benchmark, which should in theory make it easier to understand how different branches affect performance (and find opportunities for optimisation). These files can be opened directly in VS Code and other editors, or in [profiler.firefox.com](https://profiler.firefox.com), [speedscope.app](https://www.speedscope.app), Chrome's performance devtools and so on. --- .github/workflows/ci.yml | 2 +- .gitignore | 2 ++ benchmarking/compare/index.js | 19 ++++++++++++++++- benchmarking/compare/runner.js | 7 ++++++- benchmarking/run.js | 9 ++++++++- benchmarking/utils.js | 37 ++++++++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78bab024ca..df9f755874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm bench diff --git a/.gitignore b/.gitignore index d503437664..d3c1819bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ coverage tmp +benchmarking/.profiles benchmarking/compare/.results +benchmarking/compare/.profiles diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index 8f38686a29..97a297d930 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -17,6 +17,10 @@ fs.mkdirSync(outdir); const branches = []; +let PROFILE_DIR = path.resolve(filename, '../.profiles'); +if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true }); +fs.mkdirSync(PROFILE_DIR, { recursive: true }); + for (const arg of process.argv.slice(2)) { if (arg.startsWith('--')) continue; if (arg === filename) continue; @@ -44,7 +48,12 @@ for (const branch of branches) { execSync(`git checkout ${branch}`); await new Promise((fulfil, reject) => { - const child = fork(runner); + const child = fork(runner, [], { + env: { + ...process.env, + BENCH_PROFILE_DIR: `${PROFILE_DIR}/${safe(branch)}` + } + }); child.on('message', (results) => { fs.writeFileSync(`${outdir}/${branch}.json`, JSON.stringify(results, null, ' ')); @@ -57,6 +66,10 @@ for (const branch of branches) { console.groupEnd(); } +if (PROFILE_DIR !== null) { + console.log(`\nCPU profiles written to ${PROFILE_DIR}`); +} + const results = branches.map((branch) => { return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8')); }); @@ -101,3 +114,7 @@ for (let i = 0; i < results[0].length; i += 1) { function char(i) { return String.fromCharCode(97 + i); } + +function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} diff --git a/benchmarking/compare/runner.js b/benchmarking/compare/runner.js index 11e40ed983..31a8e6b44b 100644 --- a/benchmarking/compare/runner.js +++ b/benchmarking/compare/runner.js @@ -1,12 +1,17 @@ import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; +import { with_cpu_profile } from '../utils.js'; const results = []; +const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; for (let i = 0; i < reactivity_benchmarks.length; i += 1) { const benchmark = reactivity_benchmarks[i]; process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); - results.push({ benchmark: benchmark.label, ...(await benchmark.fn()) }); + results.push({ + benchmark: benchmark.label, + ...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn())) + }); process.stderr.write('\x1b[2K\r'); } diff --git a/benchmarking/run.js b/benchmarking/run.js index 2b09f7c592..80e40a5ff1 100644 --- a/benchmarking/run.js +++ b/benchmarking/run.js @@ -1,10 +1,13 @@ import * as $ from '../packages/svelte/src/internal/client/index.js'; import { reactivity_benchmarks } from './benchmarks/reactivity/index.js'; import { ssr_benchmarks } from './benchmarks/ssr/index.js'; +import { with_cpu_profile } from './utils.js'; // e.g. `pnpm bench kairo` to only run the kairo benchmarks const filters = process.argv.slice(2); +const PROFILE_DIR = './benchmarking/.profiles'; + const suites = [ { benchmarks: reactivity_benchmarks.filter( @@ -50,7 +53,7 @@ try { console.log('='.repeat(TOTAL_WIDTH)); for (const benchmark of benchmarks) { - const results = await benchmark.fn(); + const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); console.log( pad_right(benchmark.label, COLUMN_WIDTHS[0]) + pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + @@ -70,6 +73,10 @@ try { ); console.log('='.repeat(TOTAL_WIDTH)); } + + if (PROFILE_DIR !== null) { + console.log(`\nCPU profiles written to ${PROFILE_DIR}`); + } } catch (e) { // eslint-disable-next-line no-console console.error(e); diff --git a/benchmarking/utils.js b/benchmarking/utils.js index 5581135e00..b363576963 100644 --- a/benchmarking/utils.js +++ b/benchmarking/utils.js @@ -1,4 +1,7 @@ import { performance, PerformanceObserver } from 'node:perf_hooks'; +import fs from 'node:fs'; +import path from 'node:path'; +import inspector from 'node:inspector/promises'; import v8 from 'v8-natives'; // Credit to https://github.com/milomg/js-reactivity-benchmark for the logic for timing + GC tracking. @@ -41,3 +44,37 @@ export async function fastest_test(times, fn) { return results.reduce((a, b) => (a.time < b.time ? a : b)); } + +function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} + +/** + * @template T + * @param {string | null} profile_dir + * @param {string} profile_name + * @param {() => T | Promise} fn + * @returns {Promise} + */ +export async function with_cpu_profile(profile_dir, profile_name, fn) { + if (profile_dir === null) { + return await fn(); + } + + fs.mkdirSync(profile_dir, { recursive: true }); + + const session = new inspector.Session(); + session.connect(); + + await session.post('Profiler.enable'); + await session.post('Profiler.start'); + + try { + return await fn(); + } finally { + const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop')); + const file = path.join(profile_dir, `${safe(profile_name)}.cpuprofile`); + fs.writeFileSync(file, JSON.stringify(profile)); + session.disconnect(); + } +} From e402b2d0d678599a99e6fe03521f92ed922e5f27 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 1 Apr 2026 12:19:19 -0400 Subject: [PATCH 13/32] chore: write `pnpm bench:compare` report to disk (#18045) will give this a quick self-merge to unblock --- benchmarking/compare/index.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index 97a297d930..dcf5d48dbd 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'; const filename = fileURLToPath(import.meta.url); const runner = path.resolve(filename, '../runner.js'); const outdir = path.resolve(filename, '../.results'); +const report_file = `${outdir}/report.txt`; if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true }); fs.mkdirSync(outdir); @@ -74,8 +75,15 @@ const results = branches.map((branch) => { return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8')); }); +fs.writeFileSync(report_file, ''); + +const write = (str) => { + fs.appendFileSync(report_file, str + '\n'); + console.log(str); +}; + for (let i = 0; i < results[0].length; i += 1) { - console.group(`${results[0][i].benchmark}`); + write(`${results[0][i].benchmark}`); for (const metric of ['time', 'gc_time']) { const times = results.map((result) => +result[i][metric]); @@ -97,18 +105,17 @@ for (let i = 0; i < results[0].length; i += 1) { } if (min !== 0) { - console.group(`${metric}: fastest is ${char(min_index)} (${branches[min_index]})`); + write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`); times.forEach((time, b) => { const SIZE = 20; const n = Math.round(SIZE * (time / max)); - console.log(`${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`); + write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`); }); - console.groupEnd(); } } - console.groupEnd(); + write(''); } function char(i) { From 345b8ed69f0b74b91d7ede4052e302c85e3f9cd1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 1 Apr 2026 20:10:16 -0400 Subject: [PATCH 14/32] chore: keep previous benchmark runs around (#18049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extracted from #18047 – will self-merge this bit --- benchmarking/compare/index.js | 65 +++++++++++++++++++++++++---------- benchmarking/utils.js | 2 +- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index dcf5d48dbd..100d98a767 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync, fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { safe } from '../utils.js'; // if (execSync('git status --porcelain').toString().trim()) { // console.error('Working directory is not clean'); @@ -13,46 +14,59 @@ const runner = path.resolve(filename, '../runner.js'); const outdir = path.resolve(filename, '../.results'); const report_file = `${outdir}/report.txt`; -if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true }); -fs.mkdirSync(outdir); +fs.mkdirSync(outdir, { recursive: true }); -const branches = []; +const requested_branches = []; let PROFILE_DIR = path.resolve(filename, '../.profiles'); -if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true }); fs.mkdirSync(PROFILE_DIR, { recursive: true }); for (const arg of process.argv.slice(2)) { if (arg.startsWith('--')) continue; if (arg === filename) continue; - branches.push(arg); + requested_branches.push(arg); } -if (branches.length === 0) { - branches.push( +if (requested_branches.length === 0) { + requested_branches.push( execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD').toString().trim() ); } -if (branches.length === 1) { - branches.push('main'); +const original_ref = execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD') + .toString() + .trim(); + +if ( + requested_branches.length === 1 && + !requested_branches.includes('main') && + !fs.existsSync(`${outdir}/main.json`) +) { + requested_branches.push('main'); } process.on('exit', () => { - execSync(`git checkout ${branches[0]}`); + execSync(`git checkout ${original_ref}`); }); -for (const branch of branches) { +for (const branch of requested_branches) { console.group(`Benchmarking ${branch}`); + const branch_profile_dir = `${PROFILE_DIR}/${safe(branch)}`; + if (fs.existsSync(branch_profile_dir)) + fs.rmSync(branch_profile_dir, { recursive: true, force: true }); + + const branch_result_file = `${outdir}/${branch}.json`; + if (fs.existsSync(branch_result_file)) fs.rmSync(branch_result_file, { force: true }); + execSync(`git checkout ${branch}`); await new Promise((fulfil, reject) => { const child = fork(runner, [], { env: { ...process.env, - BENCH_PROFILE_DIR: `${PROFILE_DIR}/${safe(branch)}` + BENCH_PROFILE_DIR: branch_profile_dir } }); @@ -71,9 +85,20 @@ if (PROFILE_DIR !== null) { console.log(`\nCPU profiles written to ${PROFILE_DIR}`); } -const results = branches.map((branch) => { - return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8')); -}); +const result_files = fs + .readdirSync(outdir) + .filter((file) => file.endsWith('.json')) + .sort((a, b) => a.localeCompare(b)); + +const branches = result_files.map((file) => file.slice(0, -5)); +const results = result_files.map((file) => + JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) +); + +if (results.length === 0) { + console.error(`No result files found in ${outdir}`); + process.exit(1); +} fs.writeFileSync(report_file, ''); @@ -82,6 +107,12 @@ const write = (str) => { console.log(str); }; +for (let i = 0; i < branches.length; i += 1) { + write(`${char(i)}: ${branches[i]}`); +} + +write(''); + for (let i = 0; i < results[0].length; i += 1) { write(`${results[0][i].benchmark}`); @@ -121,7 +152,3 @@ for (let i = 0; i < results[0].length; i += 1) { function char(i) { return String.fromCharCode(97 + i); } - -function safe(name) { - return name.replace(/[^a-z0-9._-]+/gi, '_'); -} diff --git a/benchmarking/utils.js b/benchmarking/utils.js index b363576963..4821a167fe 100644 --- a/benchmarking/utils.js +++ b/benchmarking/utils.js @@ -45,7 +45,7 @@ export async function fastest_test(times, fn) { return results.reduce((a, b) => (a.time < b.time ? a : b)); } -function safe(name) { +export function safe(name) { return name.replace(/[^a-z0-9._-]+/gi, '_'); } From c93e251654a3193f294d6a4171d4087b4cb2fb80 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 2 Apr 2026 08:42:41 -0400 Subject: [PATCH 15/32] fix: never set derived.v inside fork (#18037) This started out as me implementing https://github.com/sveltejs/svelte/pull/17998/changes#r3018047965, but then I realised that I'd also fixed the bug that #17998 addresses. So I guess it's an alternative to that PR --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/poor-tips-send.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 21 +++++++++--------- .../internal/client/reactivity/deriveds.js | 8 ++++--- .../src/internal/client/reactivity/sources.js | 12 ++-------- .../async-fork-resolves-promise/_config.js | 22 +++++++++++++++++++ .../async-fork-resolves-promise/main.svelte | 21 ++++++++++++++++++ 6 files changed, 65 insertions(+), 24 deletions(-) create mode 100644 .changeset/poor-tips-send.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte diff --git a/.changeset/poor-tips-send.md b/.changeset/poor-tips-send.md new file mode 100644 index 0000000000..beb0329705 --- /dev/null +++ b/.changeset/poor-tips-send.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: never set derived.v inside fork diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index d53d824d03..23cc73ffc8 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -419,18 +419,22 @@ export class Batch { * Associate a change to a given source with the current * batch, noting its previous and current values * @param {Value} source - * @param {any} old_value + * @param {any} value * @param {boolean} [is_derived] */ - capture(source, old_value, is_derived = false) { - if (old_value !== UNINITIALIZED && !this.previous.has(source)) { - this.previous.set(source, old_value); + capture(source, value, is_derived = false) { + if (source.v !== UNINITIALIZED && !this.previous.has(source)) { + this.previous.set(source, source.v); } // Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get` if ((source.f & ERROR_VALUE) === 0) { - this.current.set(source, [source.v, is_derived]); - batch_values?.set(source, source.v); + this.current.set(source, [value, is_derived]); + batch_values?.set(source, value); + } + + if (!this.is_fork) { + source.v = value; } } @@ -1162,11 +1166,6 @@ export function fork(fn) { flushSync(fn); - // revert state changes - for (var [source, value] of batch.previous) { - source.v = value; - } - return { commit: async () => { if (committed) { diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5da0df0670..77acb23516 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -384,7 +384,6 @@ export function execute_derived(derived) { * @returns {void} */ export function update_derived(derived) { - var old_value = derived.v; var value = execute_derived(derived); if (!derived.equals(value)) { @@ -395,8 +394,11 @@ export function update_derived(derived) { // otherwise, the next time we get here after a 'real world' state // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { - derived.v = value; - current_batch?.capture(derived, old_value, true); + if (current_batch !== null) { + current_batch.capture(derived, value, true); + } else { + derived.v = value; + } // deriveds without dependencies should never be recomputed if (derived.deps === null) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 3ccde0f211..9235c5f673 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -180,18 +180,10 @@ export function set(source, value, should_proxy = false) { */ export function internal_set(source, value, updated_during_traversal = null) { if (!source.equals(value)) { - var old_value = source.v; - - if (is_destroying_effect) { - old_values.set(source, value); - } else { - old_values.set(source, old_value); - } - - source.v = value; + old_values.set(source, is_destroying_effect ? value : source.v); var batch = Batch.ensure(); - batch.capture(source, old_value); + batch.capture(source, value); if (DEV) { if (tracing_mode_flag || active_effect !== null) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js new file mode 100644 index 0000000000..ff323b60af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/_config.js @@ -0,0 +1,22 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, y, resolve, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + y.click(); + await tick(); + resolve.click(); + await tick(); + x.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 0'); + + await tick(); + commit.click(); + assert.htmlEqual(p.innerHTML, '1 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte new file mode 100644 index 0000000000..d6995340af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-resolves-promise/main.svelte @@ -0,0 +1,21 @@ + + +

{x} {await delay(y)}

+ + + + + From 704e3bb5f0ad25e759c97cf7c8f45be79314d4df Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 2 Apr 2026 13:49:23 -0400 Subject: [PATCH 16/32] chore: agent hints for perf investigations (#18047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds some hints in an `AGENTS.md` for how to conduct performance investigations, along with a utility for comparing profiles from different branches to identify hotspots. We will likely want to put other stuff in `AGENTS.md` in future but I figured we could start with something immediately useful. It also tweaks the comparison script — rather than nuking results from existing branches, it keeps them around so that we don't need to re-run benchmarks (which takes a long time) for branches that haven't changed. --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../skills/performance-investigation/SKILL.md | 70 ++++++++++++++++ AGENTS.md | 9 ++ benchmarking/compare/profile-diff.mjs | 83 +++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 .agents/skills/performance-investigation/SKILL.md create mode 100644 AGENTS.md create mode 100644 benchmarking/compare/profile-diff.mjs diff --git a/.agents/skills/performance-investigation/SKILL.md b/.agents/skills/performance-investigation/SKILL.md new file mode 100644 index 0000000000..cbc5d81882 --- /dev/null +++ b/.agents/skills/performance-investigation/SKILL.md @@ -0,0 +1,70 @@ +--- +name: performance-investigation +description: Investigate performance regressions and find opportunities for optimization +--- + +## Quick start + +1. Start from a branch you want to measure (for example `foo`). +2. Run: + +```sh +pnpm bench:compare main foo +``` + +If you pass one branch, `bench:compare` automatically compares it to `main`. + +## Where outputs go + +- Summary report: `benchmarking/compare/.results/report.txt` +- Raw benchmark numbers: + - `benchmarking/compare/.results/main.json` + - `benchmarking/compare/.results/.json` +- CPU profiles (per benchmark, per branch): + - `benchmarking/compare/.profiles/main/*.cpuprofile` + - `benchmarking/compare/.profiles/main/*.md` + - `benchmarking/compare/.profiles//*.cpuprofile` + - `benchmarking/compare/.profiles//*.md` + +The `.md` files are generated summaries of the CPU profile and are usually the fastest way to inspect hotspots. + +## Suggested investigation flow + +1. Open `benchmarking/compare/.results/report.txt` and identify largest regressions first. +2. For each high-delta benchmark, compare: + - `benchmarking/compare/.profiles/main/.md` + - `benchmarking/compare/.profiles//.md` +3. Look for changes in self/inclusive hotspot share in runtime internals (`runtime.js`, `reactivity/batch.js`, `reactivity/deriveds.js`, `reactivity/sources.js`). +4. Make one optimization change at a time, then re-run targeted benches before re-running full compare. + +## Fast benchmark loops + +Run only selected reactivity benchmarks by substring: + +```sh +pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle +pnpm bench repeated_deps sbench_create_signals mol_owned +``` + +## Tests to run after perf changes + +Runtime reactivity regressions are most likely in runes runtime tests: + +```sh +pnpm test runtime-runes +``` + +## Helpful script + +For quick cpuprofile hotspot deltas between two branches: + +```sh +node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo +``` + +This prints top function sample-share deltas for the selected benchmark. + +## Practical gotchas + +- `bench:compare` checks out branches while running. Avoid uncommitted changes (or stash them) so branch switching is safe. +- Each `bench:compare` run rewrites `benchmarking/compare/.results` and `benchmarking/compare/.profiles`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..c6cd3ea310 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Svelte Coding Agent Guide + +This guide is for AI coding agents working in the Svelte monorepo. + +**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. + +## Quick Reference + +If asked to do a performance investigation, use the `performance-investigation` skill. diff --git a/benchmarking/compare/profile-diff.mjs b/benchmarking/compare/profile-diff.mjs new file mode 100644 index 0000000000..c6a9061ab2 --- /dev/null +++ b/benchmarking/compare/profile-diff.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const [benchmark, baseBranch = 'main', candidateBranch] = process.argv.slice(2); + +if (!benchmark || !candidateBranch) { + console.error( + 'Usage: node benchmarking/compare/profile-diff.mjs ' + ); + process.exit(1); +} + +const root = path.resolve('benchmarking/compare/.profiles'); + +function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} + +function read_profile(branch, bench) { + const file = path.join(root, safe(branch), `${bench}.cpuprofile`); + const profile = JSON.parse(fs.readFileSync(file, 'utf8')); + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + + const id_to_node = new Map(nodes.map((node) => [node.id, node])); + const self_counts = new Map(); + + for (const sample of samples) { + if (typeof sample !== 'number') continue; + self_counts.set(sample, (self_counts.get(sample) ?? 0) + 1); + } + + const total = samples.length || 1; + const by_fn = new Map(); + + for (const [id, count] of self_counts) { + const node = id_to_node.get(id); + if (!node || typeof node !== 'object') continue; + + const frame = node.callFrame ?? {}; + const function_name = frame.functionName || '(anonymous)'; + const url = frame.url || ''; + const line = typeof frame.lineNumber === 'number' ? frame.lineNumber + 1 : 0; + + const label = url + ? `${function_name} @ ${url.replace(/^.*packages\//, 'packages/')}:${line}` + : function_name; + + by_fn.set(label, (by_fn.get(label) ?? 0) + count); + } + + return { by_fn, total }; +} + +const base = read_profile(baseBranch, benchmark); +const candidate = read_profile(candidateBranch, benchmark); + +const keys = new Set([...base.by_fn.keys(), ...candidate.by_fn.keys()]); +const rows = [...keys] + .map((key) => { + const base_pct = ((base.by_fn.get(key) ?? 0) * 100) / base.total; + const candidate_pct = ((candidate.by_fn.get(key) ?? 0) * 100) / candidate.total; + return { + key, + delta: candidate_pct - base_pct, + base_pct, + candidate_pct + }; + }) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 20); + +console.log(`Benchmark: ${benchmark}`); +console.log(`Base: ${baseBranch}`); +console.log(`Candidate: ${candidateBranch}`); +console.log(''); + +for (const row of rows) { + const sign = row.delta >= 0 ? '+' : ''; + console.log( + `${sign}${row.delta.toFixed(2).padStart(6)}pp candidate ${row.candidate_pct.toFixed(2).padStart(6)}% base ${row.base_pct.toFixed(2).padStart(6)}% ${row.key}` + ); +} From a9530e5330374fc95c06c2227a3ae7fe686b3033 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 2 Apr 2026 16:07:23 -0400 Subject: [PATCH 17/32] chore: add labels to more internal deriveds (#18050) tiny QoL thing --- packages/svelte/src/internal/client/dom/blocks/each.js | 5 +++++ packages/svelte/src/reactivity/date.js | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 3acdf80d84..5f8adee1b5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -42,6 +42,7 @@ import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; import * as e from '../../errors.js'; +import { tag } from '../../dev/tracing.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b @@ -205,6 +206,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return is_array(collection) ? collection : collection == null ? [] : array_from(collection); }); + if (DEV) { + tag(each_array, '{#each ...}'); + } + /** @type {V[]} */ var array; diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js index 8e2b5d41ab..f882c05d76 100644 --- a/packages/svelte/src/reactivity/date.js +++ b/packages/svelte/src/reactivity/date.js @@ -95,6 +95,10 @@ export class SvelteDate extends Date { return date_proto[method].apply(this, args); }); + if (DEV) { + tag(d, `SvelteDate.${method}()`); + } + this.#deriveds.set(method, d); set_active_reaction(reaction); From 206974a5cddce13106a8deb1bb977bb9a034e031 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 3 Apr 2026 15:30:16 -0400 Subject: [PATCH 18/32] chore: generate markdown tables of CPU profiles (#18059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extracted from #18035 — this should have been part of #18047 but I missed it off, oops. Will self-merge so it doesn't get in the way of #18035, and because the `performance-investigation` skill is predicated on this code existing --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- benchmarking/compare/generate-report.js | 81 ++++++++ benchmarking/compare/index.js | 70 +------ benchmarking/utils.js | 257 +++++++++++++++++++++++- 3 files changed, 338 insertions(+), 70 deletions(-) create mode 100644 benchmarking/compare/generate-report.js diff --git a/benchmarking/compare/generate-report.js b/benchmarking/compare/generate-report.js new file mode 100644 index 0000000000..a61f58909b --- /dev/null +++ b/benchmarking/compare/generate-report.js @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function generate_report(outdir) { + const result_files = fs + .readdirSync(outdir) + .filter((file) => file.endsWith('.json')) + .sort((a, b) => a.localeCompare(b)); + + const branches = result_files.map((file) => file.slice(0, -5)); + const results = result_files.map((file) => + JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) + ); + + if (results.length === 0) { + console.error(`No result files found in ${outdir}`); + process.exit(1); + } + + const report_file = path.join(outdir, 'report.txt'); + + fs.writeFileSync(report_file, ''); + + const write = (str) => { + fs.appendFileSync(report_file, str + '\n'); + console.log(str); + }; + + for (let i = 0; i < branches.length; i += 1) { + write(`${char(i)}: ${branches[i]}`); + } + + write(''); + + for (let i = 0; i < results[0].length; i += 1) { + write(`${results[0][i].benchmark}`); + + for (const metric of ['time', 'gc_time']) { + const times = results.map((result) => +result[i][metric]); + let min = Infinity; + let max = -Infinity; + let min_index = -1; + + for (let b = 0; b < times.length; b += 1) { + const time = times[b]; + + if (time < min) { + min = time; + min_index = b; + } + + if (time > max) { + max = time; + } + } + + if (min !== 0) { + write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`); + times.forEach((time, b) => { + const SIZE = 20; + const n = Math.round(SIZE * (time / max)); + + write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`); + }); + } + } + + write(''); + } +} + +function char(i) { + return String.fromCharCode(97 + i); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const outdir = path.resolve(process.argv[1], '../.results'); + + generate_report(outdir); +} diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index 100d98a767..9064ee7da9 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -3,6 +3,7 @@ import path from 'node:path'; import { execSync, fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { safe } from '../utils.js'; +import { generate_report } from './generate-report.js'; // if (execSync('git status --porcelain').toString().trim()) { // console.error('Working directory is not clean'); @@ -12,7 +13,6 @@ import { safe } from '../utils.js'; const filename = fileURLToPath(import.meta.url); const runner = path.resolve(filename, '../runner.js'); const outdir = path.resolve(filename, '../.results'); -const report_file = `${outdir}/report.txt`; fs.mkdirSync(outdir, { recursive: true }); @@ -85,70 +85,4 @@ if (PROFILE_DIR !== null) { console.log(`\nCPU profiles written to ${PROFILE_DIR}`); } -const result_files = fs - .readdirSync(outdir) - .filter((file) => file.endsWith('.json')) - .sort((a, b) => a.localeCompare(b)); - -const branches = result_files.map((file) => file.slice(0, -5)); -const results = result_files.map((file) => - JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) -); - -if (results.length === 0) { - console.error(`No result files found in ${outdir}`); - process.exit(1); -} - -fs.writeFileSync(report_file, ''); - -const write = (str) => { - fs.appendFileSync(report_file, str + '\n'); - console.log(str); -}; - -for (let i = 0; i < branches.length; i += 1) { - write(`${char(i)}: ${branches[i]}`); -} - -write(''); - -for (let i = 0; i < results[0].length; i += 1) { - write(`${results[0][i].benchmark}`); - - for (const metric of ['time', 'gc_time']) { - const times = results.map((result) => +result[i][metric]); - let min = Infinity; - let max = -Infinity; - let min_index = -1; - - for (let b = 0; b < times.length; b += 1) { - const time = times[b]; - - if (time < min) { - min = time; - min_index = b; - } - - if (time > max) { - max = time; - } - } - - if (min !== 0) { - write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`); - times.forEach((time, b) => { - const SIZE = 20; - const n = Math.round(SIZE * (time / max)); - - write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`); - }); - } - } - - write(''); -} - -function char(i) { - return String.fromCharCode(97 + i); -} +generate_report(outdir); diff --git a/benchmarking/utils.js b/benchmarking/utils.js index 4821a167fe..2f4be3c567 100644 --- a/benchmarking/utils.js +++ b/benchmarking/utils.js @@ -49,6 +49,253 @@ export function safe(name) { return name.replace(/[^a-z0-9._-]+/gi, '_'); } +/** + * @param {unknown} value + */ +function format_markdown_value(value) { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map((item) => String(item)).join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +/** + * @param {string} text + */ +function escape_markdown_cell(text) { + return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +/** + * @param {string} value + */ +function normalize_profile_url(value) { + if (!value) return ''; + + if (value.startsWith('file://')) { + try { + const pathname = decodeURIComponent(new URL(value).pathname); + const relative = path.relative(process.cwd(), pathname); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; + return pathname; + } catch { + return value; + } + } + + if (path.isAbsolute(value)) { + const relative = path.relative(process.cwd(), value); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; + } + + return value; +} + +/** + * @param {string} function_name + */ +function is_special_runtime_node(function_name) { + return function_name === '(idle)' || function_name === '(garbage collector)'; +} + +/** + * @param {string} normalized_url + */ +function is_svelte_source_url(normalized_url) { + return normalized_url.startsWith('packages/svelte/'); +} + +/** + * @param {Record} profile + */ +function profile_to_markdown(profile) { + /** @type {string[]} */ + const lines = ['# CPU profile']; + + const metadata = Object.entries(profile).filter( + ([key]) => key !== 'nodes' && key !== 'samples' && key !== 'timeDeltas' + ); + + if (metadata.length > 0) { + lines.push('', '## Metadata', '| Field | Value |', '| --- | --- |'); + for (const [key, value] of metadata) { + lines.push( + `| ${escape_markdown_cell(key)} | ${escape_markdown_cell(format_markdown_value(value))} |` + ); + } + } + + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + const timeDeltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : []; + /** @type {Set} */ + const included_node_ids = new Set(); + + if (nodes.length > 0) { + /** @type {Map>} */ + const nodes_by_id = new Map(); + + /** @type {Map} */ + const parent_by_id = new Map(); + + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + if (typeof node.id !== 'number') continue; + nodes_by_id.set(node.id, node); + const children = Array.isArray(node.children) ? node.children : []; + for (const child of children) { + if (typeof child === 'number') { + parent_by_id.set(child, node.id); + } + } + + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + const functionName = + typeof callFrame.functionName === 'string' ? callFrame.functionName : '(anonymous)'; + const normalizedUrl = + typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : ''; + + if (is_special_runtime_node(functionName) || is_svelte_source_url(normalizedUrl)) { + included_node_ids.add(node.id); + } + } + + /** @type {Map} */ + const self_sample_count = new Map(); + for (const sample of samples) { + if (typeof sample !== 'number') continue; + if (!included_node_ids.has(sample)) continue; + self_sample_count.set(sample, (self_sample_count.get(sample) ?? 0) + 1); + } + + /** @type {Map} */ + const inclusive_sample_count = new Map(); + /** @type {Set} */ + const stack = new Set(); + + /** @param {number} node_id */ + const get_inclusive_count = (node_id) => { + const cached = inclusive_sample_count.get(node_id); + if (cached !== undefined) return cached; + if (stack.has(node_id)) return self_sample_count.get(node_id) ?? 0; + + stack.add(node_id); + const node = nodes_by_id.get(node_id); + const children = node && Array.isArray(node.children) ? node.children : []; + let total = self_sample_count.get(node_id) ?? 0; + + for (const child of children) { + if (typeof child !== 'number') continue; + total += get_inclusive_count(child); + } + + stack.delete(node_id); + inclusive_sample_count.set(node_id, total); + return total; + }; + + for (const node_id of included_node_ids) { + get_inclusive_count(node_id); + } + + const total_samples = [...self_sample_count.values()].reduce((sum, count) => sum + count, 0); + if (total_samples > 0) { + const hotspot_rows = [...included_node_ids] + .map((id) => nodes_by_id.get(id)) + .filter((node) => !!node) + .map((node) => { + const id = /** @type {number} */ (node.id); + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + const functionName = + typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0 + ? callFrame.functionName + : '(anonymous)'; + const selfCount = self_sample_count.get(id) ?? 0; + const inclusiveCount = inclusive_sample_count.get(id) ?? selfCount; + return { id, functionName, selfCount, inclusiveCount }; + }) + .filter((row) => row.selfCount > 0 || row.inclusiveCount > 0) + .sort( + (a, b) => + b.inclusiveCount - a.inclusiveCount || + b.selfCount - a.selfCount || + String(a.id).localeCompare(String(b.id)) + ) + .slice(0, 25); + + if (hotspot_rows.length > 0) { + lines.push( + '', + '## Top hotspots', + '| Rank | Node ID | Function | Self samples | Self % | Inclusive samples | Inclusive % |', + '| --- | --- | --- | --- | --- | --- | --- |' + ); + + for (let i = 0; i < hotspot_rows.length; i += 1) { + const row = hotspot_rows[i]; + const selfPct = ((row.selfCount / total_samples) * 100).toFixed(2); + const inclusivePct = ((row.inclusiveCount / total_samples) * 100).toFixed(2); + lines.push( + `| ${i + 1} | ${row.id} | ${escape_markdown_cell(row.functionName)} | ${row.selfCount} | ${selfPct}% | ${row.inclusiveCount} | ${inclusivePct}% |` + ); + } + } + } + + lines.push( + '', + '## Nodes', + '| ID | Parent ID | Function | URL | Line | Column | Hit count | Children | Deopt reason |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |' + ); + + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + if (typeof node.id !== 'number') continue; + if (!included_node_ids.has(node.id)) continue; + + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + + const id = typeof node.id === 'number' ? node.id : ''; + const parentId = + typeof id === 'number' && included_node_ids.has(parent_by_id.get(id) ?? NaN) + ? parent_by_id.get(id) ?? '' + : ''; + const functionName = + typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0 + ? callFrame.functionName + : '(anonymous)'; + const url = typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : ''; + const lineNumber = + typeof callFrame.lineNumber === 'number' ? String(callFrame.lineNumber + 1) : ''; + const columnNumber = + typeof callFrame.columnNumber === 'number' ? String(callFrame.columnNumber + 1) : ''; + const hitCount = typeof node.hitCount === 'number' ? node.hitCount : ''; + const children = Array.isArray(node.children) + ? node.children + .filter((child) => typeof child === 'number' && included_node_ids.has(child)) + .join(', ') + : ''; + const deoptReason = typeof node.deoptReason === 'string' ? node.deoptReason : ''; + + lines.push( + `| ${escape_markdown_cell(String(id))} | ${escape_markdown_cell(String(parentId))} | ${escape_markdown_cell(functionName)} | ${escape_markdown_cell(url)} | ${escape_markdown_cell(lineNumber)} | ${escape_markdown_cell(columnNumber)} | ${escape_markdown_cell(String(hitCount))} | ${escape_markdown_cell(children)} | ${escape_markdown_cell(deoptReason)} |` + ); + } + } + + return `${lines.join('\n')}\n`; +} + /** * @template T * @param {string | null} profile_dir @@ -73,8 +320,14 @@ export async function with_cpu_profile(profile_dir, profile_name, fn) { return await fn(); } finally { const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop')); - const file = path.join(profile_dir, `${safe(profile_name)}.cpuprofile`); - fs.writeFileSync(file, JSON.stringify(profile)); + const safe_profile_name = safe(profile_name); + const profile_file = path.join(profile_dir, `${safe_profile_name}.cpuprofile`); + const markdown_file = path.join(profile_dir, `${safe_profile_name}.md`); + fs.writeFileSync(profile_file, JSON.stringify(profile)); + fs.writeFileSync( + markdown_file, + profile_to_markdown(/** @type {Record} */ (profile)) + ); session.disconnect(); } } From 8ee2169609716acb5661d32357df829dd78536a4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 3 Apr 2026 16:17:24 -0400 Subject: [PATCH 19/32] chore: improve benchmarks (#18061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes the benchmarks slightly more honest — it adds the async flag, and a benchmark that tests the (common) scenario where most effects are clean. Relevant to #18035, will self-merge so we can do a fresh comparison --- benchmarking/benchmarks/reactivity/index.js | 1 + .../reactivity/tests/clean_effects.bench.js | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js diff --git a/benchmarking/benchmarks/reactivity/index.js b/benchmarking/benchmarks/reactivity/index.js index 2b75b3dfc6..3fe9639376 100644 --- a/benchmarking/benchmarks/reactivity/index.js +++ b/benchmarking/benchmarks/reactivity/index.js @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import 'svelte/internal/flags/async'; import { sbench_create_0to1, sbench_create_1000to1, diff --git a/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js new file mode 100644 index 0000000000..a617554f1b --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +export default () => { + const a = $.state(1); + const b = $.state(2); + + let total = 0; + + const destroy = $.effect_root(() => { + for (let i = 0; i < 1000; i += 1) { + $.render_effect(() => { + total += $.get(a); + }); + } + + $.render_effect(() => { + total += $.get(b); + }); + }); + + return { + destroy, + run() { + for (let i = 0; i < 5; i++) { + total = 0; + $.flush(() => $.set(b, i)); + assert.equal(total, i); + } + } + }; +}; From f8ef6de3251bfd4e7c9482092b9c4dc657bb3425 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 3 Apr 2026 18:38:08 -0400 Subject: [PATCH 20/32] chore: squelch hydration warnings in tests (#18046) Gets rid of some annoying console spam. I don't totally understand why `warnings` isn't populated by the time the assertions happen (it seems to happen just afterwards, except if I sleep for a few milliseconds and then do the assertion the warnings get swallowed altogether?) but for right now I don't much care --- .../samples/async-if-after-await-in-script/_config.js | 4 +++- .../runtime-runes/samples/await-html-hydration/_config.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js index 2b8ab6e894..1003d3da05 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-if-after-await-in-script/_config.js @@ -6,11 +6,13 @@ export default test({ ssrHtml: '

yep

', - async test({ assert, target, variant }) { + async test({ assert, target, variant, warnings }) { if (variant === 'dom') { await tick(); } assert.htmlEqual(target.innerHTML, '

yep

'); + + assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet } }); diff --git a/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js index e7983a3de9..87a697b18f 100644 --- a/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/await-html-hydration/_config.js @@ -3,5 +3,7 @@ import { test } from '../../test'; export default test({ skip_no_async: true, mode: ['hydrate'], - async test() {} + async test({ assert, warnings }) { + assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet + } }); From 7a2a17557cd24c14eb1f7bbfcc6d228bb593da50 Mon Sep 17 00:00:00 2001 From: Matia <38083522+matiadev@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:24:56 +0200 Subject: [PATCH 21/32] docs: add CSS custom properties section to style directive (#18065) Adds documentation for using CSS custom properties with the `style:` directive. I think this was lost at some point during the great transition. It's only mentioned in the [best practices](https://svelte.dev/docs/svelte/best-practices#Using-JavaScript-variables-in-CSS) section, so I thought it would make sense to include it. --- documentation/docs/03-template-syntax/17-style.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/documentation/docs/03-template-syntax/17-style.md b/documentation/docs/03-template-syntax/17-style.md index 8b25c221d6..6ddb128f4a 100644 --- a/documentation/docs/03-template-syntax/17-style.md +++ b/documentation/docs/03-template-syntax/17-style.md @@ -42,3 +42,9 @@ even over `!important` properties:
This will be red
This will still be red
``` + +You can set CSS custom properties: + +```svelte +
...
+``` From 14adb8caa9eb44b3b4c38233e00740b01bb65e7e Mon Sep 17 00:00:00 2001 From: ottomated <31470743+ottomated@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:34:33 -0700 Subject: [PATCH 22/32] fix: correct types for `ontoggle` on
elements (#18063) `
` elements fire `ontoggle` as ToggleEvents ([source](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event)), but they're currently just typed as Event. ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [ ] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/great-toes-behave.md | 5 +++++ packages/svelte/elements.d.ts | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/great-toes-behave.md diff --git a/.changeset/great-toes-behave.md b/.changeset/great-toes-behave.md new file mode 100644 index 0000000000..26e36f70f1 --- /dev/null +++ b/.changeset/great-toes-behave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correct types for `ontoggle` on
elements diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 885004dd2a..f18b7dea98 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -952,9 +952,9 @@ export interface HTMLDetailsAttributes extends HTMLAttributes | undefined | null; - ontoggle?: EventHandler | undefined | null; - ontogglecapture?: EventHandler | undefined | null; + 'on:toggle'?: ToggleEventHandler | undefined | null; + ontoggle?: ToggleEventHandler | undefined | null; + ontogglecapture?: ToggleEventHandler | undefined | null; } export interface HTMLDelAttributes extends HTMLAttributes { From adba758067e0e68f9fbdcd72a6103437ec061a27 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:52:29 +0200 Subject: [PATCH 23/32] fix: don't reset status of uninitialized deriveds (#18054) If a new branch is created (e.g. if block becomes truthy) inside a fork and a derived is read inside the new branch for the first time, it will get reactions but its true value will stay uninitialized due to the fork. If the fork then commits, it will NOT change its value because it will not reexecute: there is no effect running after commit telling it to do that, because it was only read inside new effects which already ran while still inside the fork. Now, when that branch is removed (e.g. if block becomes falsy), the derived loses its reactions again and becomes disconnected, at which point the status is reset. So we end up with a maybe_dirty or clean derived and an uninitialized value, causing breakage if it's called again afterwards. The fix is to not reset the status in this case. Fixes https://github.com/sveltejs/kit/issues/15126 Fixes https://github.com/sveltejs/kit/issues/15318 Fixes https://github.com/sveltejs/kit/issues/15061 --- .changeset/silent-rings-yell.md | 5 +++++ .../svelte/src/internal/client/runtime.js | 9 ++++++++- .../samples/async-fork-if-derived/_config.js | 20 +++++++++++++++++++ .../samples/async-fork-if-derived/main.svelte | 13 ++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .changeset/silent-rings-yell.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte diff --git a/.changeset/silent-rings-yell.md b/.changeset/silent-rings-yell.md new file mode 100644 index 0000000000..0b417103f7 --- /dev/null +++ b/.changeset/silent-rings-yell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't reset status of uninitialized deriveds diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 906d68fbf0..d9578142eb 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -399,7 +399,14 @@ function remove_reaction(signal, dependency) { derived.f &= ~WAS_MARKED; } - update_derived_status(derived); + // In a fork it's possible that a derived is executed and gets reactions, then commits, but is + // never re-executed. This is possible when the derived is only executed once in the context + // of a new branch which happens before fork.commit() runs. In this case, the derived still has + // UNINITIALIZED as its value, and then when it's loosing its reactions we need to ensure it stays + // DIRTY so it is reexecuted once someone wants its value again. + if (derived.v !== UNINITIALIZED) { + update_derived_status(derived); + } // freeze any effects inside this derived freeze_derived_effects(derived); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js new file mode 100644 index 0000000000..4a03a54e7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [, toggle] = target.querySelectorAll('button'); + + toggle?.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 0`); + + toggle?.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` `); + + toggle?.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 0`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte new file mode 100644 index 0000000000..519857a9ce --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-if-derived/main.svelte @@ -0,0 +1,13 @@ + + + + + +{#if show} + {d_count} +{/if} From d86cb5cc327a29e7e509ada93a3338d2118aab93 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:59:37 +0200 Subject: [PATCH 24/32] fix: skip rebase logic in non-async mode (#18040) Fixes part of #17940 (the hydration->error thing still needs a repro). Essentially in sync mode render effects are executed during traversing the effect tree, and when flushSync is called during that it can cause the timing of things getting out of sync such that you end up wanting to rebase another branch, which is never needed in sync mode. So we just skip that logic in sync mode. Another way would be to adjust flushSync such that it does appends itself to the end of the current flushing cycle if it notices processing is already ongoing. This will not work when you pass a callback function to `flushSync` though - another indicator that we should probably remove that callback argument. --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/ripe-mails-wave.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 5 ++++- .../flush-sync-each-block/Inner.svelte | 8 ++++++++ .../samples/flush-sync-each-block/_config.js | 20 +++++++++++++++++++ .../samples/flush-sync-each-block/main.svelte | 12 +++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .changeset/ripe-mails-wave.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte diff --git a/.changeset/ripe-mails-wave.md b/.changeset/ripe-mails-wave.md new file mode 100644 index 0000000000..6ccc1c724d --- /dev/null +++ b/.changeset/ripe-mails-wave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: skip rebase logic in non-async mode diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 23cc73ffc8..f98436e7bc 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -349,7 +349,9 @@ export class Batch { next_batch.#process(); } - if (!batches.has(this)) { + // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode + // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed + if (async_mode_flag && !batches.has(this)) { this.#commit(); } } @@ -791,6 +793,7 @@ export class Batch { } } +// TODO Svelte@6 think about removing the callback argument. /** * Synchronously flush any pending updates. * Returns void if no callback is provided, otherwise returns the result of calling the callback. diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte new file mode 100644 index 0000000000..6d7c982db0 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte @@ -0,0 +1,8 @@ + + +{value} diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js new file mode 100644 index 0000000000..2798f533c6 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 2 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte new file mode 100644 index 0000000000..3be1202c97 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte @@ -0,0 +1,12 @@ + + + + + + +{#each [count] as row} + {row} +{/each} From cd5bda00a81687df415d6b35ad1b16109f847216 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 6 Apr 2026 12:06:41 -0400 Subject: [PATCH 25/32] chore: fix changeset (#18073) prevents the changelog from getting borked up --- .changeset/great-toes-behave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/great-toes-behave.md b/.changeset/great-toes-behave.md index 26e36f70f1..5f2845e364 100644 --- a/.changeset/great-toes-behave.md +++ b/.changeset/great-toes-behave.md @@ -2,4 +2,4 @@ 'svelte': patch --- -fix: correct types for `ontoggle` on
elements +fix: correct types for `ontoggle` on `
` elements From 0395ef0df7ff12c4b633b650c5f2db512d382836 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:11:08 +0200 Subject: [PATCH 26/32] fix: unskip branches of earlier batches after commit (#18048) Fixes #17571 where the situation is the following: A derived creates a new query. That query initializes loading with true. This means the if block is marked for destruction (therefore effects inside branch are skipped), but it's not doing that yet because the query promise is pending. Then query resolves and loading is set back to false right before resolving, but it's not the same tick so `loading=false` is a separate thing. Because that later batch doesn't see any overlap with an earlier batch (the earlier batch did set loading to true but not via set but indirectly via recreating the query) it doesn't wait on it and flushes right away. Now the if block is marked as visible again but the earlier batch doesn't know that if noone unskips its branch. If we don't do that the render effect that is now dirty as part of that batch will not run. --- .changeset/petite-signs-flash.md | 5 +++ .../src/internal/client/reactivity/batch.js | 28 +++++++++++-- .../samples/async-if-block-unskip/_config.js | 30 ++++++++++++++ .../samples/async-if-block-unskip/main.svelte | 40 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 .changeset/petite-signs-flash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte diff --git a/.changeset/petite-signs-flash.md b/.changeset/petite-signs-flash.md new file mode 100644 index 0000000000..38a1c7b47c --- /dev/null +++ b/.changeset/petite-signs-flash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: unskip branches of earlier batches after commit diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index f98436e7bc..9c0832150a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -172,6 +172,12 @@ export class Batch { */ #skipped_branches = new Map(); + /** + * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing + * @type {Set} + */ + #unskipped_branches = new Set(); + is_fork = false; #decrement_queued = false; @@ -215,28 +221,31 @@ export class Batch { if (!this.#skipped_branches.has(effect)) { this.#skipped_branches.set(effect, { d: [], m: [] }); } + this.#unskipped_branches.delete(effect); } /** * Remove an effect from the #skipped_branches map and reschedule * any tracked dirty/maybe_dirty child effects * @param {Effect} effect + * @param {(e: Effect) => void} callback */ - unskip_effect(effect) { + unskip_effect(effect, callback = (e) => this.schedule(e)) { var tracked = this.#skipped_branches.get(effect); if (tracked) { this.#skipped_branches.delete(effect); for (var e of tracked.d) { set_signal_status(e, DIRTY); - this.schedule(e); + callback(e); } for (e of tracked.m) { set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); + callback(e); } } + this.#unskipped_branches.add(effect); } #process() { @@ -532,6 +541,19 @@ export class Batch { invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); } + // A batch was unskipped in a later batch -> tell prior batches to unskip it, too + if (is_earlier) { + for (const unskipped of this.#unskipped_branches) { + batch.unskip_effect(unskipped, (e) => { + if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) { + batch.schedule(e); + } else { + batch.#defer_effects([e]); + } + }); + } + } + batch.activate(); /** @type {Set} */ diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js new file mode 100644 index 0000000000..d578def483 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [load, resolve] = target.querySelectorAll('button'); + + load.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + search search search search + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte new file mode 100644 index 0000000000..36ebcd26d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte @@ -0,0 +1,40 @@ + + +{query} {await promise} + +{#if !promise.loading} + {query} +{/if} + +{#if !promise.loading} + {await query} +{/if} + + + From 8966601dcd14582cd46d4fbb7c5cf1b444292255 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 6 Apr 2026 17:42:32 -0400 Subject: [PATCH 27/32] fix: handle parens in template expressions more robustly (#18075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While looking into #17954 I realised that a) our code for handling parentheses in expressions is unnecessarily convoluted and b) it doesn't handle the case where you have an opening parent outside the first comment — this fails to parse: ```svelte {(/**/ 42)} ``` This fixes it and simplifies the code a good bit. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/dull-cows-tie.md | 5 ++ .../src/compiler/phases/1-parse/acorn.js | 31 ++++++---- .../compiler/phases/1-parse/read/context.js | 20 +++--- .../phases/1-parse/read/expression.js | 40 +----------- .../src/compiler/phases/1-parse/state/tag.js | 7 +-- .../parser-modern/samples/parens/input.svelte | 1 + .../parser-modern/samples/parens/output.json | 61 +++++++++++++++++++ 7 files changed, 100 insertions(+), 65 deletions(-) create mode 100644 .changeset/dull-cows-tie.md create mode 100644 packages/svelte/tests/parser-modern/samples/parens/input.svelte create mode 100644 packages/svelte/tests/parser-modern/samples/parens/output.json diff --git a/.changeset/dull-cows-tie.md b/.changeset/dull-cows-tie.md new file mode 100644 index 0000000000..9835805dce --- /dev/null +++ b/.changeset/dull-cows-tie.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle parens in template expressions more robustly diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 77ce4a461c..797ab4cea5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -1,5 +1,6 @@ /** @import { Comment, Program } from 'estree' */ /** @import { AST } from '#compiler' */ +/** @import { Parser } from './index.js' */ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; @@ -66,26 +67,22 @@ export function parse(source, comments, typescript, is_script) { } /** + * @param {Parser} parser * @param {string} source - * @param {Comment[]} comments - * @param {boolean} typescript * @param {number} index * @returns {acorn.Expression & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }} */ -export function parse_expression_at(source, comments, typescript, index) { - const parser = typescript ? ParserWithTS : acorn.Parser; +export function parse_expression_at(parser, source, index) { + const _ = parser.ts ? ParserWithTS : acorn.Parser; - const { onComment, add_comments } = get_comment_handlers( - source, - /** @type {CommentWithLocation[]} */ (comments), - index - ); + const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index); - const ast = parser.parseExpressionAt(source, index, { + const ast = _.parseExpressionAt(source, index, { onComment, sourceType: 'module', ecmaVersion: 16, - locations: true + locations: true, + preserveParens: true }); add_comments(ast); @@ -93,6 +90,18 @@ export function parse_expression_at(source, comments, typescript, index) { return ast; } +/** + * @param {acorn.Expression} node + * @returns {acorn.Expression} + */ +export function remove_parens(node) { + return walk(node, null, { + ParenthesizedExpression(node, context) { + return context.visit(node.expression); + } + }); +} + /** * Acorn doesn't add comments to the AST by itself. This factory returns the capabilities * to add them after the fact. They are needed in order to support `svelte-ignore` comments diff --git a/packages/svelte/src/compiler/phases/1-parse/read/context.js b/packages/svelte/src/compiler/phases/1-parse/read/context.js index f90d59fa0b..24b7e2c6b0 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/context.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/context.js @@ -1,7 +1,7 @@ /** @import { Pattern } from 'estree' */ /** @import { Parser } from '../index.js' */ import { match_bracket } from '../utils/bracket.js'; -import { parse_expression_at } from '../acorn.js'; +import { parse_expression_at, remove_parens } from '../acorn.js'; import { regex_not_newline_characters } from '../../patterns.js'; import * as e from '../../../errors.js'; @@ -49,14 +49,12 @@ export default function read_pattern(parser) { space_with_newline = space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1); - const expression = /** @type {any} */ ( - parse_expression_at( - `${space_with_newline}(${pattern_string} = 1)`, - parser.root.comments, - parser.ts, - start - 1 - ) - ).left; + /** @type {any} */ + let expression = remove_parens( + parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1) + ); + + expression = expression.left; expression.typeAnnotation = read_type_annotation(parser); if (expression.typeAnnotation) { @@ -92,13 +90,13 @@ function read_type_annotation(parser) { // parameters as part of a sequence expression instead, and will then error on optional // parameters (`?:`). Therefore replace that sequence with something that will not error. parser.template.slice(parser.index).replace(/\?\s*:/g, ':'); - let expression = parse_expression_at(template, parser.root.comments, parser.ts, a); + let expression = remove_parens(parse_expression_at(parser, template, a)); // `foo: bar = baz` gets mangled — fix it if (expression.type === 'AssignmentExpression') { let b = expression.right.start; while (template[b] !== '=') b -= 1; - expression = parse_expression_at(template.slice(0, b), parser.root.comments, parser.ts, a); + expression = remove_parens(parse_expression_at(parser, template.slice(0, b), a)); } // `array as item: string, index` becomes `string, index`, which is mistaken as a sequence expression - fix that diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 5d21f85792..16d4c4e50f 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -1,6 +1,6 @@ /** @import { Expression } from 'estree' */ /** @import { Parser } from '../index.js' */ -import { parse_expression_at } from '../acorn.js'; +import { parse_expression_at, remove_parens } from '../acorn.js'; import { regex_whitespace } from '../../patterns.js'; import * as e from '../../../errors.js'; import { find_matching_bracket } from '../utils/bracket.js'; @@ -34,50 +34,16 @@ export function get_loose_identifier(parser, opening_token) { */ export default function read_expression(parser, opening_token, disallow_loose) { try { - let comment_index = parser.root.comments.length; - - const node = parse_expression_at( - parser.template, - parser.root.comments, - parser.ts, - parser.index - ); - - let num_parens = 0; - - let i = parser.root.comments.length; - while (i-- > comment_index) { - const comment = parser.root.comments[i]; - if (comment.end < node.start) { - parser.index = comment.end; - break; - } - } - - for (let i = parser.index; i < /** @type {number} */ (node.start); i += 1) { - if (parser.template[i] === '(') num_parens += 1; - } + const node = parse_expression_at(parser, parser.template, parser.index); let index = /** @type {number} */ (node.end); const last_comment = parser.root.comments.at(-1); if (last_comment && last_comment.end > index) index = last_comment.end; - while (num_parens > 0) { - const char = parser.template[index]; - - if (char === ')') { - num_parens -= 1; - } else if (!regex_whitespace.test(char)) { - e.expected_token(index, ')'); - } - - index += 1; - } - parser.index = index; - return /** @type {Expression} */ (node); + return /** @type {Expression} */ (remove_parens(node)); } catch (err) { // If we are in an each loop we need the error to be thrown in cases like // `as { y = z }` so we still throw and handle the error there diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index d9518c726f..ff153128a5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -392,12 +392,7 @@ function open(parser) { let function_expression = matched ? /** @type {ArrowFunctionExpression} */ ( - parse_expression_at( - prelude + `${params} => {}`, - parser.root.comments, - parser.ts, - params_start - ) + parse_expression_at(parser, prelude + `${params} => {}`, params_start) ) : { params: [] }; diff --git a/packages/svelte/tests/parser-modern/samples/parens/input.svelte b/packages/svelte/tests/parser-modern/samples/parens/input.svelte new file mode 100644 index 0000000000..ad4f4b7940 --- /dev/null +++ b/packages/svelte/tests/parser-modern/samples/parens/input.svelte @@ -0,0 +1 @@ +{(/**/ 42)} diff --git a/packages/svelte/tests/parser-modern/samples/parens/output.json b/packages/svelte/tests/parser-modern/samples/parens/output.json new file mode 100644 index 0000000000..7a5b4b38d8 --- /dev/null +++ b/packages/svelte/tests/parser-modern/samples/parens/output.json @@ -0,0 +1,61 @@ +{ + "css": null, + "js": [], + "start": 0, + "end": 11, + "type": "Root", + "fragment": { + "type": "Fragment", + "nodes": [ + { + "type": "ExpressionTag", + "start": 0, + "end": 11, + "expression": { + "type": "Literal", + "start": 7, + "end": 9, + "loc": { + "start": { + "line": 1, + "column": 7 + }, + "end": { + "line": 1, + "column": 9 + } + }, + "value": 42, + "raw": "42", + "leadingComments": [ + { + "type": "Block", + "value": "", + "start": 2, + "end": 6 + } + ] + } + } + ] + }, + "options": null, + "comments": [ + { + "type": "Block", + "value": "", + "start": 2, + "end": 6, + "loc": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 6 + } + } + } + ] +} From 6b653b8d17c80b16659c5238875977f0941490c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 7 Apr 2026 15:06:21 -0400 Subject: [PATCH 28/32] chore: simplify parser (#18077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit small tidy-up spurred by #17954 — rather than try-catching every `parse_expression_at` call and passing the error to `parser.acorn_error`, we can handle the error locally and get rid of that method ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../src/compiler/phases/1-parse/acorn.js | 66 ++++++++++++------- .../src/compiler/phases/1-parse/index.js | 10 --- .../compiler/phases/1-parse/read/context.js | 54 +++++++-------- .../phases/1-parse/read/expression.js | 2 +- .../compiler/phases/1-parse/read/script.js | 9 +-- 5 files changed, 69 insertions(+), 72 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 797ab4cea5..45a7c2a58c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -4,8 +4,10 @@ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; +import * as e from '../../errors.js'; -const ParserWithTS = acorn.Parser.extend(tsPlugin()); +const JSParser = acorn.Parser; +const TSParser = JSParser.extend(tsPlugin()); /** * @typedef {Comment & { @@ -21,15 +23,15 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin()); * @param {boolean} [is_script] */ export function parse(source, comments, typescript, is_script) { - const parser = typescript ? ParserWithTS : acorn.Parser; + const acorn = typescript ? TSParser : JSParser; const { onComment, add_comments } = get_comment_handlers( source, /** @type {CommentWithLocation[]} */ (comments) ); - // @ts-ignore - const parse_statement = parser.prototype.parseStatement; + // @ts-expect-error + const parse_statement = acorn.prototype.parseStatement; // If we're dealing with a