From 0adc22c9ae5f98718bf5b7b83753767cc919b0e1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 12:43:17 -0400 Subject: [PATCH 01/41] fix: defer batch resolution until earlier intersecting batches have committed (#17162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes an awkward bug with `each` blocks containing `await`, especially keyed `each` blocks. If you do `array.push(...)` multiple times in distinct batches, something weird happens — to the `each` block, the array looks this... ```js [1] ``` ...then this... ```js [undefined, 2] ``` ...then this... ```js [undefined, undefined, 3] ``` ...and so on. That's because as far as Svelte's reactivity is concerned, what we're _really_ doing is assigning to `array[0]` then `array[1]` then `array[2]`. Those (along with `array.length`) are each backed by independent sources, which we can rewind individually when we need to 'apply' a batch. When it comes to sources that actually _are_ independent, this is useful, since we apply the changes from batch B to the DOM while we're still waiting for a promise in batch A to resolve. But in this case it's not ideal, because you would expect these changes to accumulate. In particular, this fails with keyed `each` blocks because duplicate keys are disallowed (assuming the key function doesn't break on `undefined` _before_ the duplicate check happens). This PR fixes it by stacking batches that are interconnected. Specifically, if a later batch has some (but not all) sources in common with an earlier batch, then when we apply the batch we include the sources from the earlier batch, and block it until the earlier batch commits. When the earlier batch commits, it will check to see if doing so unblocks any later batches, and if so process them. In the course of working on this I realised that `SvelteSet` and `SvelteMap` aren't async-ready — will follow up this PR with ones for those. Fixes #17050 --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen --- .changeset/spicy-teeth-tan.md | 5 + .../src/internal/client/reactivity/async.js | 7 +- .../src/internal/client/reactivity/batch.js | 148 ++++++++++--- .../internal/client/reactivity/deriveds.js | 2 +- .../samples/async-derived-indirect/_config.js | 29 +++ .../async-derived-indirect/main.svelte | 21 ++ .../samples/async-each-overlap/_config.js | 102 --------- .../async-ignore-skipped-block/_config.js | 29 +++ .../async-ignore-skipped-block/main.svelte | 14 ++ .../async-later-sync-overlaps/_config.js | 30 +++ .../async-later-sync-overlaps/main.svelte | 17 ++ .../async-overlapping-array/_config.js | 205 ++++++++++++++++++ .../main.svelte | 8 +- 13 files changed, 474 insertions(+), 143 deletions(-) create mode 100644 .changeset/spicy-teeth-tan.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte delete mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js rename packages/svelte/tests/runtime-runes/samples/{async-each-overlap => async-overlapping-array}/main.svelte (84%) diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md new file mode 100644 index 0000000000..c7efa65130 --- /dev/null +++ b/.changeset/spicy-teeth-tan.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: defer batch resolution until earlier intersecting batches have committed diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 9b24400840..d713385c75 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -307,15 +307,16 @@ export function wait(blockers) { * @returns {(skip?: boolean) => void} */ export function increment_pending() { - var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); + var effect = /** @type {Effect} */ (active_effect); + var boundary = /** @type {Boundary} */ (effect.b); var batch = /** @type {Batch} */ (current_batch); var blocking = boundary.is_rendered(); boundary.update_pending_count(1, batch); - batch.increment(blocking); + batch.increment(blocking, effect); return (skip = false) => { boundary.update_pending_count(-1, batch); - batch.decrement(blocking, skip); + batch.decrement(blocking, effect, skip); }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b100d559d2..3b10d6ebe6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -90,20 +90,20 @@ var source_stacks = DEV ? new Set() : null; let uid = 1; export class Batch { - // for debugging. TODO remove once async is stable id = uid++; /** - * The current values of any sources that are updated in this batch + * The current values of any signals that are updated in this batch. + * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) * They keys of this map are identical to `this.#previous` - * @type {Map} + * @type {Map} */ current = new Map(); /** - * The values of any sources that are updated in this batch _before_ those updates took place. + * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. * They keys of this map are identical to `this.#current` - * @type {Map} + * @type {Map} */ previous = new Map(); @@ -121,14 +121,16 @@ export class Batch { #discard_callbacks = new Set(); /** - * The number of async effects that are currently in flight + * Async effects that are currently in flight + * @type {Map} */ - #pending = 0; + #pending = new Map(); /** - * The number of async effects that are currently in flight, _not_ inside a pending boundary + * Async effects that are currently in flight, _not_ inside a pending boundary + * @type {Map} */ - #blocking_pending = 0; + #blocking_pending = new Map(); /** * A deferred that resolves when the batch is committed, used with `settled()` @@ -168,8 +170,35 @@ export class Batch { #decrement_queued = false; + /** @type {Set} */ + #blockers = new Set(); + #is_deferred() { - return this.is_fork || this.#blocking_pending > 0; + return this.is_fork || this.#blocking_pending.size > 0; + } + + #is_blocked() { + for (const batch of this.#blockers) { + for (const effect of batch.#blocking_pending.keys()) { + var skipped = false; + var e = effect; + + while (e.parent !== null) { + if (this.#skipped_branches.has(e)) { + skipped = true; + break; + } + + e = e.parent; + } + + if (!skipped) { + return true; + } + } + } + + return false; } /** @@ -264,7 +293,7 @@ export class Batch { collected_effects = null; legacy_updates = null; - if (this.#is_deferred()) { + if (this.#is_deferred() || this.#is_blocked()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -272,7 +301,7 @@ export class Batch { reset_branch(e, t); } } else { - if (this.#pending === 0) { + if (this.#pending.size === 0) { batches.delete(this); } @@ -383,17 +412,18 @@ export class Batch { /** * Associate a change to a given source with the current * batch, noting its previous and current values - * @param {Source} source + * @param {Value} source * @param {any} old_value + * @param {boolean} [is_derived] */ - capture(source, old_value) { + capture(source, old_value, is_derived = false) { if (old_value !== UNINITIALIZED && !this.previous.has(source)) { this.previous.set(source, old_value); } // Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get` if ((source.f & ERROR_VALUE) === 0) { - this.current.set(source, source.v); + this.current.set(source, [source.v, is_derived]); batch_values?.set(source, source.v); } } @@ -453,11 +483,13 @@ export class Batch { /** @type {Source[]} */ var sources = []; - for (const [source, value] of this.current) { + for (const [source, [value, is_derived]] of this.current) { if (batch.current.has(source)) { - if (is_earlier && value !== batch.current.get(source)) { + var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring + + if (is_earlier && value !== batch_value) { // bring the value up to date - batch.current.set(source, value); + batch.current.set(source, [value, is_derived]); } else { // same value or later batch has more recent value, // no need to re-run these effects @@ -507,24 +539,56 @@ export class Batch { batch.deactivate(); } } + + for (const batch of batches) { + if (batch.#blockers.has(this)) { + batch.#blockers.delete(this); + + if (batch.#blockers.size === 0 && !batch.#is_deferred()) { + batch.activate(); + batch.#process(); + } + } + } } /** - * * @param {boolean} blocking + * @param {Effect} effect */ - increment(blocking) { - this.#pending += 1; - if (blocking) this.#blocking_pending += 1; + increment(blocking, effect) { + let pending_count = this.#pending.get(effect) ?? 0; + this.#pending.set(effect, pending_count + 1); + + if (blocking) { + let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; + this.#blocking_pending.set(effect, blocking_pending_count + 1); + } } /** * @param {boolean} blocking + * @param {Effect} effect * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ - decrement(blocking, skip) { - this.#pending -= 1; - if (blocking) this.#blocking_pending -= 1; + decrement(blocking, effect, skip) { + let pending_count = this.#pending.get(effect) ?? 0; + + if (pending_count === 1) { + this.#pending.delete(effect); + } else { + this.#pending.set(effect, pending_count - 1); + } + + if (blocking) { + let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; + + if (blocking_pending_count === 1) { + this.#blocking_pending.delete(effect); + } else { + this.#blocking_pending.set(effect, blocking_pending_count - 1); + } + } if (this.#decrement_queued || skip) return; this.#decrement_queued = true; @@ -597,15 +661,37 @@ export class Batch { // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch... - batch_values = new Map(this.current); + batch_values = new Map(); + for (const [source, [value]] of this.current) { + batch_values.set(source, value); + } - // ...and undo changes belonging to other batches + // ...and undo changes belonging to other batches unless they block this one for (const batch of batches) { if (batch === this || batch.is_fork) continue; - for (const [source, previous] of batch.previous) { - if (!batch_values.has(source)) { - batch_values.set(source, previous); + // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset + var intersects = false; + var differs = false; + + if (batch.id < this.id) { + for (const [source, [, is_derived]] of batch.current) { + // Derived values don't partake in the blocking mechanism, because a derived could + // be triggered in one batch already but not the other one yet, causing a false-positive + if (is_derived) continue; + + intersects ||= this.current.has(source); + differs ||= !this.current.has(source); + } + } + + if (intersects && differs) { + this.#blockers.add(batch); + } else { + for (const [source, previous] of batch.previous) { + if (!batch_values.has(source)) { + batch_values.set(source, previous); + } } } } @@ -1065,7 +1151,7 @@ export function fork(fn) { batch.is_fork = false; // apply changes and update write versions so deriveds see the change - for (var [source, value] of batch.current) { + for (var [source, [value]] of batch.current) { source.v = value; source.wv = increment_write_version(); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7b932a8356..5da0df0670 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -396,7 +396,7 @@ export function update_derived(derived) { // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { derived.v = value; - current_batch?.capture(derived, old_value); + current_batch?.capture(derived, old_value, true); // deriveds without dependencies should never be recomputed if (derived.deps === null) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js new file mode 100644 index 0000000000..9d97b736be --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, b, resolve] = target.querySelectorAll('button'); + + a.click(); + await tick(); + b.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + hi + 1 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte new file mode 100644 index 0000000000..f0d3bdc809 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte @@ -0,0 +1,21 @@ + + + + + + +{#if a_b}hi{/if} +{await push(a_b)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js deleted file mode 100644 index d03f9ad09d..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js +++ /dev/null @@ -1,102 +0,0 @@ -import { tick } from 'svelte'; -import { test } from '../../test'; - -export default test({ - skip: true, - async test({ assert, target }) { - const [add, shift] = target.querySelectorAll('button'); - - add.click(); - await tick(); - add.click(); - await tick(); - add.click(); - await tick(); - - // TODO pending count / number of pushes is off - - assert.htmlEqual( - target.innerHTML, - ` - - -

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

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

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

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

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

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

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

-
not keyed: -
1
-
2
-
3
-
4
-
-
keyed: -
1
-
2
-
3
-
4
-
- ` - ); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js new file mode 100644 index 0000000000..1d1a489d67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [a, b] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

hello

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

hello

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

hello

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

hello

`); + + // if we don't skip over the never-resolving promise in the `else` block, we will never update + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

hello

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte new file mode 100644 index 0000000000..4ef0694d40 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte @@ -0,0 +1,14 @@ + + + + + +{#if show} +

hello

+{:else} + {await new Promise(() => {})} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js new file mode 100644 index 0000000000..a65c8a6fba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b, b, resolve] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte new file mode 100644 index 0000000000..8a9e2a866c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte @@ -0,0 +1,17 @@ + + + + + +{await push(a)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js new file mode 100644 index 0000000000..2dad5babea --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js @@ -0,0 +1,205 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [add, shift, pop] = target.querySelectorAll('button'); + + add.click(); + await tick(); + add.click(); + await tick(); + add.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

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

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

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

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

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

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

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

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

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

+
not keyed: +
1
+
2
+
3
+
4
+
+
keyed: +
1
+
2
+
3
+
4
+
+ ` + ); + + // pop should have no effect until earlier promises have also resolved + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

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

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

pending=0 values.length=8 values=[1,2,3,4,5,6,7,8]

+
not keyed: +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+
keyed: +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte similarity index 84% rename from packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte rename to packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte index af9d395457..e41b6926b8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte @@ -11,18 +11,14 @@ return p.promise; } - function shift() { - const fn = queue.shift(); - if (fn) fn(); - } - function addValue() { values.push(values.length+1); } - + +

pending={$effect.pending()} From 803c565febd07be8fd08377ffc51f23d7bf6b8f3 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:37:21 +0100 Subject: [PATCH 02/41] fix: properly invoke `iterator.return()` during reactivity loss check (#17966) The logic was flipped - `iterator.return()` should be called when the iterator is abnormally ending, not when it's normally ending. Fixes #16610 --- .changeset/tasty-carrots-tie.md | 5 +++ .../src/internal/client/reactivity/async.js | 4 +- .../_config.js | 21 ++++++++++ .../main.svelte | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 .changeset/tasty-carrots-tie.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte diff --git a/.changeset/tasty-carrots-tie.md b/.changeset/tasty-carrots-tie.md new file mode 100644 index 0000000000..5d6cb8025d --- /dev/null +++ b/.changeset/tasty-carrots-tie.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: properly invoke `iterator.return()` during reactivity loss check diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index d713385c75..7b0b108e4c 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -209,8 +209,8 @@ export async function* for_await_track_reactivity_loss(iterable) { yield value; } } finally { - // If the iterator had a normal completion and `return` is defined on the iterator, call it and return the value - if (normal_completion && iterator.return !== undefined) { + // If the iterator had an abrupt completion and `return` is defined on the iterator, call it and return the value + if (!normal_completion && iterator.return !== undefined) { // eslint-disable-next-line no-unsafe-finally return /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js new file mode 100644 index 0000000000..3429380800 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + html: '

pending

', + async test({ assert, target, warnings }) { + await tick(); + + assert.htmlEqual(target.innerHTML, '

number -> number -> number -> return -> ended

'); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte new file mode 100644 index 0000000000..05ad7a6ee5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/main.svelte @@ -0,0 +1,41 @@ + + + +

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
From 425fba33fed0572e952d364d1c59f7af01aedd07 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Fri, 20 Mar 2026 22:23:57 +0100 Subject: [PATCH 03/41] fix: hydration comments during hmr (#17975) Closes #17972 Claude found this fix I had a look and I think it makes sense (we are injecting a new comment which messes up the marching during hydration). I'm slightly confused why this only applied to `css_props` but I guess that's because there's an "hidden" element there which makes it special. I don't super-like the `if` situation, but I guess it is what it is. Also the test was using `hmr` without `dev` and this brought to my attention that we were just assuming components return something while it's not the case...I guess it doesn't really matter unless you are using `hmr` in prod which is not a thing but fixing this is simple so we might just as well doing it --- .changeset/calm-mice-allow.md | 5 ++++ .../svelte/src/internal/client/dev/hmr.js | 25 +++++++++++++------ .../samples/css-props-hmr/Component.svelte | 7 ++++++ .../samples/css-props-hmr/_config.js | 7 ++++++ .../samples/css-props-hmr/main.svelte | 5 ++++ 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 .changeset/calm-mice-allow.md create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/_config.js create mode 100644 packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte diff --git a/.changeset/calm-mice-allow.md b/.changeset/calm-mice-allow.md new file mode 100644 index 0000000000..eff71e1082 --- /dev/null +++ b/.changeset/calm-mice-allow.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: hydration comments during hmr diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 071901aafc..4687708c90 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -34,7 +34,13 @@ export function hmr(fn) { let start = create_comment(); let end = create_comment(); - anchor.before(start); + // During hydration, inserting the start comment before the anchor could + // corrupt the DOM tree that the hydration walker is navigating (e.g. when + // a component is inside a CSS props wrapper gh-issue#17972). We defer the insertion until + // after the component has hydrated. + if (!hydrating) { + anchor.before(start); + } block(() => { if (component === (component = get(current))) { @@ -52,13 +58,13 @@ export function hmr(fn) { if (ran) set_should_intro(false); // preserve getters/setters - Object.defineProperties( - instance, - Object.getOwnPropertyDescriptors( - // @ts-expect-error - new.target ? new component(anchor, props) : component(anchor, props) - ) - ); + var result = + // @ts-expect-error + new.target ? new component(anchor, props) : component(anchor, props); + // a component is not guaranteed to return something and we can't invoke getOwnPropertyDescriptors on undefined + if (result) { + Object.defineProperties(instance, Object.getOwnPropertyDescriptors(result)); + } if (ran) set_should_intro(true); }); @@ -67,6 +73,9 @@ export function hmr(fn) { ran = true; if (hydrating) { + // Insert start comment now that hydration is done, so it doesn't + // corrupt the hydration walk + anchor.before(start); anchor = hydrate_node; } diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte b/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte new file mode 100644 index 0000000000..ac73501cca --- /dev/null +++ b/packages/svelte/tests/hydration/samples/css-props-hmr/Component.svelte @@ -0,0 +1,7 @@ +

Hello

+ + \ No newline at end of file diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js b/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js new file mode 100644 index 0000000000..2cd696d584 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/css-props-hmr/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + hmr: true + } +}); diff --git a/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte b/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte new file mode 100644 index 0000000000..eed9b57caf --- /dev/null +++ b/packages/svelte/tests/hydration/samples/css-props-hmr/main.svelte @@ -0,0 +1,5 @@ + + + \ No newline at end of file From 6b33dd2a1e8aa48dc88c9ce6e19c4a49a2eac51a Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:29:39 +0100 Subject: [PATCH 04/41] fix: group sync statements (#17977) We were just putting each statement into its own promise. Besides this being bad for perf, it also introduces subtle timing issues - the execution order of the code could change in bad ways. Fixes #17940 --- .changeset/puny-masks-run.md | 5 + .../src/compiler/phases/2-analyze/index.js | 36 ++++- .../3-transform/shared/transform-async.js | 124 ++++++++++-------- .../svelte/src/compiler/phases/types.d.ts | 2 +- .../_expected/client/index.svelte.js | 16 ++- .../_expected/server/index.svelte.js | 16 ++- .../async-top-level-group-sync-run/_config.js | 3 + .../_expected/client/index.svelte.js | 26 ++++ .../_expected/server/index.svelte.js | 21 +++ .../index.svelte | 9 ++ 10 files changed, 184 insertions(+), 74 deletions(-) create mode 100644 .changeset/puny-masks-run.md create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte diff --git a/.changeset/puny-masks-run.md b/.changeset/puny-masks-run.md new file mode 100644 index 0000000000..162abe4ae7 --- /dev/null +++ b/.changeset/puny-masks-run.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: group sync statements diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index cadd159b3e..dec0081aa9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -1074,6 +1074,9 @@ function calculate_blockers(instance, analysis) { let awaited = false; + /** @type {Array} */ + let sync_group = []; + // TODO this should probably be attached to the scope? const promises = b.id('$$promises'); @@ -1088,6 +1091,13 @@ function calculate_blockers(instance, analysis) { binding.blocker = blocker; } + function flush_sync_group() { + if (sync_group.length === 0) return; + + analysis.instance_body.async.push({ nodes: sync_group, has_await: false }); + sync_group = []; + } + /** * Analysis of blockers for functions is deferred until we know which statements are async/blockers * @type {Array} @@ -1149,6 +1159,9 @@ function calculate_blockers(instance, analysis) { trace_references(declarator, reads, writes, instance.scope); + // Needs to happen before blocker computation + if (has_await) flush_sync_group(); + const blocker = /** @type {NonNullable} */ ( b.member(promises, b.literal(analysis.instance_body.async.length), true) ); @@ -1161,11 +1174,12 @@ function calculate_blockers(instance, analysis) { push_declaration(id, blocker); } - // one declarator per declaration, makes things simpler - analysis.instance_body.async.push({ - node: declarator, - has_await - }); + if (has_await) { + // one declarator per declaration, makes things simpler + analysis.instance_body.async.push({ nodes: [declarator], has_await: true }); + } else { + sync_group.push(declarator); + } } } } else if (awaited) { @@ -1177,6 +1191,9 @@ function calculate_blockers(instance, analysis) { trace_references(node, reads, writes, instance.scope); + // Needs to happen before blocker computation + if (has_await) flush_sync_group(); + const blocker = /** @type {NonNullable} */ ( b.member(promises, b.literal(analysis.instance_body.async.length), true) ); @@ -1187,15 +1204,20 @@ function calculate_blockers(instance, analysis) { if (node.type === 'ClassDeclaration') { push_declaration(node.id, blocker); - analysis.instance_body.async.push({ node, has_await }); + } + + if (has_await) { + analysis.instance_body.async.push({ nodes: [node], has_await: true }); } else { - analysis.instance_body.async.push({ node, has_await }); + sync_group.push(node); } } else { analysis.instance_body.sync.push(node); } } + flush_sync_group(); + for (const fn of functions) { /** @type {Set} */ const reads_writes = new Set(); diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js index 5c8f901d7c..ac90f1db4b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js @@ -47,64 +47,24 @@ export function transform_body(instance_body, runner, transform) { // Thunks for the await expressions if (instance_body.async.length > 0) { - const thunks = instance_body.async.map((s) => { - if (s.node.type === 'VariableDeclarator') { - const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ ( - transform(b.var(s.node.id, s.node.init)) - ); - - const statements = - visited.type === 'VariableDeclaration' - ? visited.declarations.map((node) => { - if ( - node.id.type === 'Identifier' && - (node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array')) - ) { - // this is an intermediate declaration created in VariableDeclaration.js; - // subsequent statements depend on it - return b.var(node.id, node.init); - } - - return b.stmt(b.assignment('=', node.id, node.init ?? b.void0)); - }) - : []; - - if (statements.length === 1) { - const statement = /** @type {ESTree.ExpressionStatement} */ (statements[0]); - return b.thunk(statement.expression, s.has_await); - } - - return b.thunk(b.block(statements), s.has_await); - } + const thunks = instance_body.async.map((entry) => { + /** @type {ESTree.Statement[]} */ + const entry_statements = []; - if (s.node.type === 'ClassDeclaration') { - return b.thunk( - b.assignment( - '=', - s.node.id, - /** @type {ESTree.ClassExpression} */ ({ ...s.node, type: 'ClassExpression' }) - ), - s.has_await - ); + for (const node of entry.nodes) { + entry_statements.push(...transform_async_node(node, transform)); } - if (s.node.type === 'ExpressionStatement') { - // the expression may be a $inspect call, which will be transformed into an empty statement - const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ ( - transform(s.node.expression) - ); - - if (expression.type === 'EmptyStatement') { - // Keep indices stable for async sequencing while avoiding array holes in run([...]). - return b.thunk(b.void0, false); - } + if (entry_statements.length === 0) { + // Keep indices stable for async sequencing while avoiding array holes in run([...]). + return b.thunk(b.void0, false); + } - return expression.type === 'AwaitExpression' - ? b.thunk(expression, true) - : b.thunk(b.unary('void', expression), s.has_await); + if (entry_statements.length === 1 && entry_statements[0].type === 'ExpressionStatement') { + return b.thunk(entry_statements[0].expression, entry.has_await); } - return b.thunk(b.block([/** @type {ESTree.Statement} */ (transform(s.node))]), s.has_await); + return b.thunk(b.block(entry_statements), entry.has_await); }); // TODO get the `$$promises` ID from scope @@ -113,3 +73,63 @@ export function transform_body(instance_body, runner, transform) { return statements; } + +/** + * @param {ESTree.Statement | ESTree.VariableDeclarator} node + * @param {(node: ESTree.Node) => ESTree.Node} transform + * @returns {ESTree.Statement[]} + */ +function transform_async_node(node, transform) { + if (node.type === 'VariableDeclarator') { + const visited = /** @type {ESTree.VariableDeclaration | ESTree.EmptyStatement} */ ( + transform(b.var(node.id, node.init)) + ); + + return visited.type === 'VariableDeclaration' + ? visited.declarations.map((node) => { + if ( + node.id.type === 'Identifier' && + (node.id.name.startsWith('$$d') || node.id.name.startsWith('$$array')) + ) { + // This intermediate declaration is created in VariableDeclaration.js; + // subsequent statements may depend on it. + return b.var(node.id, node.init); + } + + return b.stmt(b.assignment('=', node.id, node.init ?? b.void0)); + }) + : []; + } + + if (node.type === 'ClassDeclaration') { + return [ + b.stmt( + b.assignment( + '=', + node.id, + /** @type {ESTree.ClassExpression} */ ({ ...node, type: 'ClassExpression' }) + ) + ) + ]; + } + + if (node.type === 'ExpressionStatement') { + // The expression may be a $inspect call, which will be transformed into an empty statement. + const expression = /** @type {ESTree.Expression | ESTree.EmptyStatement} */ ( + transform(node.expression) + ); + + if (expression.type === 'EmptyStatement') { + return []; + } + + if (expression.type === 'AwaitExpression') { + return [b.stmt(expression)]; + } + + return [b.stmt(b.unary('void', expression))]; + } + + const statement = /** @type {ESTree.Statement | ESTree.EmptyStatement} */ (transform(node)); + return statement.type === 'EmptyStatement' ? [] : [statement]; +} diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts index 5397ea45f9..a1a85ce145 100644 --- a/packages/svelte/src/compiler/phases/types.d.ts +++ b/packages/svelte/src/compiler/phases/types.d.ts @@ -131,7 +131,7 @@ export interface ComponentAnalysis extends Analysis { instance_body: { hoisted: Array; sync: Array; - async: Array<{ node: Statement | VariableDeclarator; has_await: boolean }>; + async: Array<{ nodes: Array; has_await: boolean }>; declarations: Array; }; } diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js index a8b1a43495..4f06d9ddbf 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js @@ -10,13 +10,15 @@ export default function Async_in_derived($$anchor, $$props) { var $$promises = $.run([ async () => yes1 = await $.async_derived(() => 1), async () => yes2 = await $.async_derived(async () => foo(await 1)), - () => no1 = $.derived(async () => { - return await 1; - }), - - () => no2 = $.derived(() => async () => { - return await 1; - }) + () => { + no1 = $.derived(async () => { + return await 1; + }); + + no2 = $.derived(() => async () => { + return await 1; + }); + } ]); var fragment = $.comment(); diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js index 1697e3adc6..3a53475944 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js @@ -8,13 +8,15 @@ export default function Async_in_derived($$renderer, $$props) { var $$promises = $$renderer.run([ async () => yes1 = await $.async_derived(() => 1), async () => yes2 = await $.async_derived(async () => foo(await 1)), - () => no1 = $.derived(async () => { - return await 1; - }), - - () => no2 = $.derived(() => async () => { - return await 1; - }) + () => { + no1 = $.derived(async () => { + return await 1; + }); + + no2 = $.derived(() => async () => { + return await 1; + }); + } ]); if (true) { diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js new file mode 100644 index 0000000000..2e30bbeb16 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({ compileOptions: { experimental: { async: true } } }); diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js new file mode 100644 index 0000000000..8fb09fadd2 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js @@ -0,0 +1,26 @@ +import 'svelte/internal/disclose-version'; +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/client'; + +export default function Async_top_level_group_sync_run($$anchor) { + var a, + // these should be grouped into one, having an async tick inbetween + // would change how the code runs and could introduce subtle timing bugs + b, + c; + + var $$promises = $.run([ + async () => a = await Promise.resolve(1), + () => { + b = a + 1; + c = b + 1; + } + ]); + + $.next(); + + var text = $.text(); + + $.template_effect(() => $.set_text(text, c), void 0, void 0, [$$promises[1]]); + $.append($$anchor, text); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js new file mode 100644 index 0000000000..43db129746 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/server/index.svelte.js @@ -0,0 +1,21 @@ +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/server'; + +export default function Async_top_level_group_sync_run($$renderer) { + var a, + // these should be grouped into one, having an async tick inbetween + // would change how the code runs and could introduce subtle timing bugs + b, + c; + + var $$promises = $$renderer.run([ + async () => a = await Promise.resolve(1), + () => { + b = a + 1; + c = b + 1; + } + ]); + + $$renderer.push(``); + $$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(c))); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte new file mode 100644 index 0000000000..98f602312e --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/index.svelte @@ -0,0 +1,9 @@ + + +{c} From 8e4de9b14500e195ec7f084c8c32b1782b31c5c9 Mon Sep 17 00:00:00 2001 From: Nitin Sahu Date: Sat, 21 Mar 2026 18:03:45 +0530 Subject: [PATCH 05/41] fix: null out effect.b in destroy_effect to prevent memory leak (#17980) ## Fix: #17881 ### Root Cause When a dynamic component switches (e.g. ``), branch effects are destroyed via `destroy_effect()`. However, the `effect.b` (Boundary reference) field was not cleared alongside other references (`next`, `prev`, `ctx`, `deps`, `fn`, `nodes`, `ac`). As a result, destroyed effects retained a reference to the `Boundary` instance, which holds references to component state and child effects. This prevented destroyed component subtrees from being garbage collected, causing memory usage to grow during repeated component switching. ### Solution Clear the boundary reference during effect destruction. ```diff effect.next = effect.prev = effect.teardown = effect.ctx = effect.deps = effect.fn = effect.nodes = effect.ac = + effect.b = null; ``` ### Test Plan * All existing tests pass: * runtime-runes: 2,459 * runtime-legacy: 3,294 * signals: 96 * Total: 5,849 tests * Added a dynamic component switching test that toggles components repeatedly and verifies correct DOM output. ### Validation Reproduced the issue using the example from #17881. After applying the fix: * Memory usage stabilizes during repeated component switching * Destroyed components are properly reclaimed by the garbage collector * No behavioral regressions observed ### Impact * Fixes memory leak in dynamic component switching * Minimal, safe change * No API or behavior changes --------- Co-authored-by: Rich Harris --- .changeset/fix-memory-leak-boundary.md | 5 +++++ packages/svelte/src/internal/client/reactivity/effects.js | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/fix-memory-leak-boundary.md diff --git a/.changeset/fix-memory-leak-boundary.md b/.changeset/fix-memory-leak-boundary.md new file mode 100644 index 0000000000..8a3b084981 --- /dev/null +++ b/.changeset/fix-memory-leak-boundary.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: null out `effect.b` in `destroy_effect` diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index aeffeedddd..54c8a17d79 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -559,6 +559,7 @@ export function destroy_effect(effect, remove_dom = true) { effect.fn = effect.nodes = effect.ac = + effect.b = null; } From 7123bf3a131a53b1ba9414f0d44150a94d0132e3 Mon Sep 17 00:00:00 2001 From: Abishek Raj R R Date: Sat, 21 Mar 2026 18:04:31 +0530 Subject: [PATCH 06/41] fix: remove trailing semicolon from {@const} tag printer (#17962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #17720 ## Problem The `{@const}` tag was being printed with a trailing semicolon, producing invalid Svelte syntax like: {@const a = 1;} This happened because the `ConstTag` visitor in the printer was delegating to esrap's `VariableDeclaration` handler, which always appends a semicolon (correct for JS, but wrong for Svelte template syntax). ## Solution Instead of delegating to `VariableDeclaration`, the `ConstTag` visitor now manually prints the tag by: - Writing `{@const ` directly - Iterating through declarators and visiting each one - Separating multiple declarations with commas - Closing with `}` — no trailing semicolon ## Before {@const a = 1;} {@const a = 1, b = 2;} ## After {@const a = 1} {@const a = 1, b = 2} ## Changes - `packages/svelte/src/compiler/print/index.js` — fixed `ConstTag` visitor - `packages/svelte/tests/print/samples/const-tag/output.svelte` — updated expected test output --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/two-onions-end.md | 5 +++++ packages/svelte/src/compiler/print/index.js | 9 +++++++-- .../svelte/tests/print/samples/const-tag/output.svelte | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .changeset/two-onions-end.md diff --git a/.changeset/two-onions-end.md b/.changeset/two-onions-end.md new file mode 100644 index 0000000000..9e6a56bbf9 --- /dev/null +++ b/.changeset/two-onions-end.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: remove trailing semicolon from {@const} tag printer diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index 160aeb4345..26dc1b88e8 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -592,8 +592,13 @@ const svelte_visitors = (comments) => ({ }, ConstTag(node, context) { - context.write('{@'); - context.visit(node.declaration); + context.write('{@const '); + const declarators = node.declaration.declarations; + for (let i = 0; i < declarators.length; i++) { + if (i > 0) context.write(', '); + context.visit(declarators[i]); + } + context.write('}'); }, diff --git a/packages/svelte/tests/print/samples/const-tag/output.svelte b/packages/svelte/tests/print/samples/const-tag/output.svelte index d9d8addcbb..dded998a84 100644 --- a/packages/svelte/tests/print/samples/const-tag/output.svelte +++ b/packages/svelte/tests/print/samples/const-tag/output.svelte @@ -3,6 +3,6 @@ {#each boxes as box} - {@const area = box.width * box.height;} + {@const area = box.width * box.height} {box.width} * {box.height} = {area} {/each} From db69b7e345090edd4aa7949c52478adc05a61d61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 09:29:56 -0400 Subject: [PATCH 07/41] Version Packages (#17965) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.54.1 ### Patch Changes - fix: hydration comments during hmr ([#17975](https://github.com/sveltejs/svelte/pull/17975)) - fix: null out `effect.b` in `destroy_effect` ([#17980](https://github.com/sveltejs/svelte/pull/17980)) - fix: group sync statements ([#17977](https://github.com/sveltejs/svelte/pull/17977)) - fix: defer batch resolution until earlier intersecting batches have committed ([#17162](https://github.com/sveltejs/svelte/pull/17162)) - fix: properly invoke `iterator.return()` during reactivity loss check ([#17966](https://github.com/sveltejs/svelte/pull/17966)) - fix: remove trailing semicolon from {@const} tag printer ([#17962](https://github.com/sveltejs/svelte/pull/17962)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/calm-mice-allow.md | 5 ----- .changeset/fix-memory-leak-boundary.md | 5 ----- .changeset/puny-masks-run.md | 5 ----- .changeset/spicy-teeth-tan.md | 5 ----- .changeset/tasty-carrots-tie.md | 5 ----- .changeset/two-onions-end.md | 5 ----- packages/svelte/CHANGELOG.md | 16 ++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 9 files changed, 18 insertions(+), 32 deletions(-) delete mode 100644 .changeset/calm-mice-allow.md delete mode 100644 .changeset/fix-memory-leak-boundary.md delete mode 100644 .changeset/puny-masks-run.md delete mode 100644 .changeset/spicy-teeth-tan.md delete mode 100644 .changeset/tasty-carrots-tie.md delete mode 100644 .changeset/two-onions-end.md diff --git a/.changeset/calm-mice-allow.md b/.changeset/calm-mice-allow.md deleted file mode 100644 index eff71e1082..0000000000 --- a/.changeset/calm-mice-allow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: hydration comments during hmr diff --git a/.changeset/fix-memory-leak-boundary.md b/.changeset/fix-memory-leak-boundary.md deleted file mode 100644 index 8a3b084981..0000000000 --- a/.changeset/fix-memory-leak-boundary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: null out `effect.b` in `destroy_effect` diff --git a/.changeset/puny-masks-run.md b/.changeset/puny-masks-run.md deleted file mode 100644 index 162abe4ae7..0000000000 --- a/.changeset/puny-masks-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: group sync statements diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md deleted file mode 100644 index c7efa65130..0000000000 --- a/.changeset/spicy-teeth-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: defer batch resolution until earlier intersecting batches have committed diff --git a/.changeset/tasty-carrots-tie.md b/.changeset/tasty-carrots-tie.md deleted file mode 100644 index 5d6cb8025d..0000000000 --- a/.changeset/tasty-carrots-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: properly invoke `iterator.return()` during reactivity loss check diff --git a/.changeset/two-onions-end.md b/.changeset/two-onions-end.md deleted file mode 100644 index 9e6a56bbf9..0000000000 --- a/.changeset/two-onions-end.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: remove trailing semicolon from {@const} tag printer diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 7c1311ae84..16cbf92ea4 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,21 @@ # svelte +## 5.54.1 + +### Patch Changes + +- fix: hydration comments during hmr ([#17975](https://github.com/sveltejs/svelte/pull/17975)) + +- fix: null out `effect.b` in `destroy_effect` ([#17980](https://github.com/sveltejs/svelte/pull/17980)) + +- fix: group sync statements ([#17977](https://github.com/sveltejs/svelte/pull/17977)) + +- fix: defer batch resolution until earlier intersecting batches have committed ([#17162](https://github.com/sveltejs/svelte/pull/17162)) + +- fix: properly invoke `iterator.return()` during reactivity loss check ([#17966](https://github.com/sveltejs/svelte/pull/17966)) + +- fix: remove trailing semicolon from {@const} tag printer ([#17962](https://github.com/sveltejs/svelte/pull/17962)) + ## 5.54.0 ### Minor Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 270d02d621..afbdfa956c 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.54.0", + "version": "5.54.1", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 191d53016a..7de1242e62 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.54.0'; +export const VERSION = '5.54.1'; export const PUBLIC_VERSION = '5'; From 0c669d9bc98f7a7dfc5da666deaf4f672e07c847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=A8=E5=B8=86?= <39647285+leno23@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:09:14 +0800 Subject: [PATCH 08/41] docs: recommend createContext as the primary method for context (#17959) ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [ ] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [ ] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Rich Harris --- documentation/docs/06-runtime/02-context.md | 124 +++++++++++++++----- 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/documentation/docs/06-runtime/02-context.md b/documentation/docs/06-runtime/02-context.md index 61b203f93e..86009c9a20 100644 --- a/documentation/docs/06-runtime/02-context.md +++ b/documentation/docs/06-runtime/02-context.md @@ -2,7 +2,66 @@ title: Context --- -Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling'). The parent component sets context with `setContext(key, value)`... +Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling'). + +By creating a `[get, set]` pair of functions with `createContext`, you can set the context in a parent component and get it in a child component: + + +```svelte + + + + + + +``` + +```svelte + + + +{@render children()} +``` + +```svelte + + + +

hello {user.name}, inside Child.svelte

+``` + +```ts +/// file: context.ts +import { createContext } from 'svelte'; + +interface User { + name: string; +} + +export const [getUserContext, setUserContext] = createContext(); +``` + + +> [!NOTE] `createContext` was added in version 5.40. If you are using an earlier version of Svelte, you must use `setContext` and `getContext` instead. + +This is particularly useful when `Parent.svelte` is not directly aware of `Child.svelte`, but instead renders it as part of a `children` [snippet](snippet) as shown above. + +## `setContext` and `getContext` + +As an alternative to `createContext`, you can use `setContext` and `getContext` directly. The parent component sets context with `setContext(key, value)`... ```svelte @@ -26,32 +85,28 @@ Context allows components to access values owned by parent components without pa

{message}, inside Child.svelte

``` -This is particularly useful when `Parent.svelte` is not directly aware of `Child.svelte`, but instead renders it as part of a `children` [snippet](snippet) ([demo](/playground/untitled#H4sIAAAAAAAAE42Q3W6DMAyFX8WyJgESK-oto6hTX2D3YxcM3IIUQpR40yqUd58CrCXsp7tL7HNsf2dAWXaEKR56yfTBGOOxFWQwfR6Qz8q1XAHjL-GjUhvzToJd7bU09FO9ctMkG0wxM5VuFeeFLLjtVK8ZnkpNkuGo-w6CTTJ9Z3PwsBAemlbUF934W8iy5DpaZtOUcU02-ZLcaS51jHEkTFm_kY1_wfOO8QnXrb8hBzDEc6pgZ4gFoyz4KgiD7nxfTe8ghqAhIfrJ46cTzVZBbkPlODVJsLCDO6V7ZcJoncyw1yRr0hd1GNn_ZbEM3I9i1bmVxOlWElUvDUNHxpQngt3C4CXzjS1rtvkw22wMrTRtTbC8Lkuabe7jvthPPe3DofYCAAA=)): - -```svelte - - - -``` - The key (`'my-context'`, in the example above) and the context itself can be any JavaScript value. +> [!NOTE] `createContext` is preferred since it provides better type safety and makes it unnecessary to use keys. + In addition to [`setContext`](svelte#setContext) and [`getContext`](svelte#getContext), Svelte exposes [`hasContext`](svelte#hasContext) and [`getAllContexts`](svelte#getAllContexts) functions. ## Using context with state -You can store reactive state in context ([demo](/playground/untitled#H4sIAAAAAAAAE41R0W6DMAz8FSuaBNUQdK8MkKZ-wh7HHihzu6hgosRMm1D-fUpSVNq12x4iEvvOx_kmQU2PIhfP3DCCJGgHYvxkkYid7NCI_GUS_KUcxhVEMjOelErNB3bsatvG4LW6n0ZsRC4K02qpuKqpZtmrQTNMYJA3QRAs7PTQQxS40eMCt3mX3duxnWb-lS5h7nTI0A4jMWoo4c44P_Hku-zrOazdy64chWo-ScfRkRgl8wgHKrLTH1OxHZkHgoHaTraHcopXUFYzPPVfuC_hwQaD1GrskdiNCdQwJljJqlvXfyqVsA5CGg0uRUQifHw56xFtciO75QrP07vo_JXf_tf8yK2ezDKY_ZWt_1y2qqYzv7bI1IW1V_sN19m-07wCAAA=))... +You can store reactive state in context... + ```svelte + +``` + +```svelte + + + +

{counter.count}

+``` + +```ts +/// file: context.ts +import { createContext } from 'svelte'; + +interface Counter { + count: number; +} + +export const [getCounter, setCounter] = createContext(); ``` + ...though note that if you _reassign_ `counter` instead of updating it, you will 'break the link' — in other words instead of this... ```svelte - ``` @@ -81,21 +163,7 @@ You can store reactive state in context ([demo](/playground/untitled#H4sIAAAAAAA Svelte will warn you if you get it wrong. -## Type-safe context - -As an alternative to using `setContext` and `getContext` directly, you can use them via `createContext`. This gives you type safety and makes it unnecessary to use a key: - -```ts -/// file: context.ts -// @filename: ambient.d.ts -interface User {} - -// @filename: index.ts -// ---cut--- -import { createContext } from 'svelte'; - -export const [getUserContext, setUserContext] = createContext(); -``` +## Component testing When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing: From a94924b67721e8b3151ebf1622691b1902c9bf85 Mon Sep 17 00:00:00 2001 From: moaaz <145723871+moaaz-ae@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:29:52 +0200 Subject: [PATCH 09/41] fix: export TweenOptions, SpringOptions, SpringUpdateOptions and Updater from svelte/motion (#17967) Exports `TweenedOptions`, `SpringOpts`, `SpringUpdateOpts`, and `Updater` from `svelte/motion`. These types are required for the public method signatures of `spring` and `tweened` (e.g., as parameters for `.set()` and `.update()`). This PR makes them accessible to TypeScript users, following the established pattern in modules like `svelte/store` and `svelte/transition`. Internal implementation details like `TickContext` remain private as they do not appear in any public-facing signatures. Fixes #16151 ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/angry-knives-bow.md | 5 ++ packages/svelte/src/motion/private.d.ts | 34 --------- packages/svelte/src/motion/public.d.ts | 49 ++++++++++--- packages/svelte/src/motion/spring.js | 14 ++-- packages/svelte/src/motion/tweened.js | 15 ++-- packages/svelte/tests/types/motion.ts | 22 ++++++ packages/svelte/types/index.d.ts | 91 +++++++++++++------------ 7 files changed, 128 insertions(+), 102 deletions(-) create mode 100644 .changeset/angry-knives-bow.md create mode 100644 packages/svelte/tests/types/motion.ts diff --git a/.changeset/angry-knives-bow.md b/.changeset/angry-knives-bow.md new file mode 100644 index 0000000000..c96b397886 --- /dev/null +++ b/.changeset/angry-knives-bow.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: export TweenOptions, SpringOptions, SpringUpdateOptions and Updater from svelte/motion diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index 22b8cc4af3..a6e0f95284 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -8,37 +8,3 @@ export interface TickContext { }; settled: boolean; } - -export interface SpringOpts { - stiffness?: number; - damping?: number; - precision?: number; -} - -export interface SpringUpdateOpts { - /** - * @deprecated Only use this for the spring store; does nothing when set on the Spring class - */ - hard?: any; - /** - * @deprecated Only use this for the spring store; does nothing when set on the Spring class - */ - soft?: string | number | boolean; - /** - * Only use this for the Spring class; does nothing when set on the spring store - */ - instant?: boolean; - /** - * Only use this for the Spring class; does nothing when set on the spring store - */ - preserveMomentum?: number; -} - -export type Updater = (target_value: T, value: T) => T; - -export interface TweenedOptions { - delay?: number; - duration?: number | ((from: T, to: T) => number); - easing?: (t: number) => number; - interpolate?: (a: T, b: T) => (t: number) => T; -} diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index 4e74d4b76f..89145d0383 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -1,16 +1,49 @@ import { Readable, type Unsubscriber } from '../store/public.js'; -import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private.js'; + +export interface SpringOptions { + stiffness?: number; + damping?: number; + precision?: number; +} + +export interface SpringUpdateOptions { + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ + hard?: any; + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ + soft?: string | number | boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + instant?: boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + preserveMomentum?: number; +} + +export type Updater = (target_value: T, value: T) => T; + +export interface TweenOptions { + delay?: number; + duration?: number | ((from: T, to: T) => number); + easing?: (t: number) => number; + interpolate?: (a: T, b: T) => (t: number) => T; +} // TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface) // this means both the Spring class and the Spring interface are merged into one with some things only // existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js export interface Spring extends Readable { - set(new_value: T, opts?: SpringUpdateOpts): Promise; + set(new_value: T, opts?: SpringUpdateOptions): Promise; /** * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ - update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + update: (fn: Updater, opts?: SpringUpdateOptions) => Promise; /** * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ @@ -37,7 +70,7 @@ export interface Spring extends Readable { * @since 5.8.0 */ export class Spring { - constructor(value: T, options?: SpringOpts); + constructor(value: T, options?: SpringOptions); /** * Create a spring whose value is bound to the return value of `fn`. This must be called @@ -53,7 +86,7 @@ export class Spring { * * ``` */ - static of(fn: () => U, options?: SpringOpts): Spring; + static of(fn: () => U, options?: SpringOptions): Spring; /** * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. @@ -63,7 +96,7 @@ export class Spring { * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for * the specified number of milliseconds. This is useful for things like 'fling' gestures. */ - set(value: T, options?: SpringUpdateOpts): Promise; + set(value: T, options?: SpringUpdateOptions): Promise; damping: number; precision: number; @@ -81,8 +114,8 @@ export class Spring { } export interface Tweened extends Readable { - set(value: T, opts?: TweenedOptions): Promise; - update(updater: Updater, opts?: TweenedOptions): Promise; + set(value: T, opts?: TweenOptions): Promise; + update(updater: Updater, opts?: TweenOptions): Promise; } export { prefersReducedMotion, spring, tweened, Tween } from './index.js'; diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 44be1a501b..7198d60127 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -1,6 +1,6 @@ /** @import { Task } from '#client' */ -/** @import { SpringOpts, SpringUpdateOpts, TickContext } from './private.js' */ -/** @import { Spring as SpringStore } from './public.js' */ +/** @import { TickContext } from './private.js' */ +/** @import { Spring as SpringStore, SpringOptions, SpringUpdateOptions } from './public.js' */ import { writable } from '../store/shared/index.js'; import { loop } from '../internal/client/loop.js'; import { raf } from '../internal/client/timing.js'; @@ -62,7 +62,7 @@ function tick_spring(ctx, last_value, current_value, target_value) { * @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead * @template [T=any] * @param {T} [value] - * @param {SpringOpts} [opts] + * @param {SpringOptions} [opts] * @returns {SpringStore} */ export function spring(value, opts = {}) { @@ -83,7 +83,7 @@ export function spring(value, opts = {}) { let cancel_task = false; /** * @param {T} new_value - * @param {SpringUpdateOpts} opts + * @param {SpringUpdateOptions} opts * @returns {Promise} */ function set(new_value, opts = {}) { @@ -191,7 +191,7 @@ export class Spring { /** * @param {T} value - * @param {SpringOpts} [options] + * @param {SpringOptions} [options] */ constructor(value, options = {}) { this.#current = DEV ? tag(state(value), 'Spring.current') : state(value); @@ -225,7 +225,7 @@ export class Spring { * ``` * @template U * @param {() => U} fn - * @param {SpringOpts} [options] + * @param {SpringOptions} [options] */ static of(fn, options) { const spring = new Spring(fn(), options); @@ -293,7 +293,7 @@ export class Spring { * the specified number of milliseconds. This is useful for things like 'fling' gestures. * * @param {T} value - * @param {SpringUpdateOpts} [options] + * @param {SpringUpdateOptions} [options] */ set(value, options) { this.#deferred?.reject(new Error('Aborted')); diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index 437c22ec3b..a24148d075 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -1,6 +1,5 @@ /** @import { Task } from '../internal/client/types' */ -/** @import { Tweened } from './public' */ -/** @import { TweenedOptions } from './private' */ +/** @import { Tweened, TweenOptions } from './public' */ import { writable } from '../store/shared/index.js'; import { raf } from '../internal/client/timing.js'; import { loop } from '../internal/client/loop.js'; @@ -84,7 +83,7 @@ function get_interpolator(a, b) { * @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead * @template T * @param {T} [value] - * @param {TweenedOptions} [defaults] + * @param {TweenOptions} [defaults] * @returns {Tweened} */ export function tweened(value, defaults = {}) { @@ -94,7 +93,7 @@ export function tweened(value, defaults = {}) { let target_value = value; /** * @param {T} new_value - * @param {TweenedOptions} [opts] + * @param {TweenOptions} [opts] */ function set(new_value, opts) { target_value = new_value; @@ -180,7 +179,7 @@ export class Tween { #current; #target; - /** @type {TweenedOptions} */ + /** @type {TweenOptions} */ #defaults; /** @type {import('../internal/client/types').Task | null} */ @@ -188,7 +187,7 @@ export class Tween { /** * @param {T} value - * @param {TweenedOptions} options + * @param {TweenOptions} options */ constructor(value, options = {}) { this.#current = state(value); @@ -216,7 +215,7 @@ export class Tween { * ``` * @template U * @param {() => U} fn - * @param {TweenedOptions} [options] + * @param {TweenOptions} [options] */ static of(fn, options) { const tween = new Tween(fn(), options); @@ -233,7 +232,7 @@ export class Tween { * * If `options` are provided, they will override the tween's defaults. * @param {T} value - * @param {TweenedOptions} [options] + * @param {TweenOptions} [options] * @returns */ set(value, options) { diff --git a/packages/svelte/tests/types/motion.ts b/packages/svelte/tests/types/motion.ts new file mode 100644 index 0000000000..0ba5d1d9c6 --- /dev/null +++ b/packages/svelte/tests/types/motion.ts @@ -0,0 +1,22 @@ +import { + type TweenOptions, + type SpringOptions, + type SpringUpdateOptions, + type Updater +} from 'svelte/motion'; + +let tweenOptions: TweenOptions = { + delay: 100, + duration: 400 +}; + +let springOptions: SpringOptions = { + stiffness: 0.1, + damping: 0.5 +}; + +let springUpdateOptions: SpringUpdateOptions = { + instant: true +}; + +let updater: Updater = (target, value) => target + value; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 371a823225..019baf45dd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2005,16 +2005,50 @@ declare module 'svelte/legacy' { declare module 'svelte/motion' { import type { MediaQuery } from 'svelte/reactivity'; + export interface SpringOptions { + stiffness?: number; + damping?: number; + precision?: number; + } + + export interface SpringUpdateOptions { + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ + hard?: any; + /** + * @deprecated Only use this for the spring store; does nothing when set on the Spring class + */ + soft?: string | number | boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + instant?: boolean; + /** + * Only use this for the Spring class; does nothing when set on the spring store + */ + preserveMomentum?: number; + } + + export type Updater = (target_value: T, value: T) => T; + + export interface TweenOptions { + delay?: number; + duration?: number | ((from: T, to: T) => number); + easing?: (t: number) => number; + interpolate?: (a: T, b: T) => (t: number) => T; + } + // TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface) // this means both the Spring class and the Spring interface are merged into one with some things only // existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js export interface Spring extends Readable { - set(new_value: T, opts?: SpringUpdateOpts): Promise; + set(new_value: T, opts?: SpringUpdateOptions): Promise; /** * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ - update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + update: (fn: Updater, opts?: SpringUpdateOptions) => Promise; /** * @deprecated Only exists on the legacy `spring` store, not the `Spring` class */ @@ -2041,7 +2075,7 @@ declare module 'svelte/motion' { * @since 5.8.0 */ export class Spring { - constructor(value: T, options?: SpringOpts); + constructor(value: T, options?: SpringOptions); /** * Create a spring whose value is bound to the return value of `fn`. This must be called @@ -2057,7 +2091,7 @@ declare module 'svelte/motion' { * * ``` */ - static of(fn: () => U, options?: SpringOpts): Spring; + static of(fn: () => U, options?: SpringOptions): Spring; /** * Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it. @@ -2067,7 +2101,7 @@ declare module 'svelte/motion' { * If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for * the specified number of milliseconds. This is useful for things like 'fling' gestures. */ - set(value: T, options?: SpringUpdateOpts): Promise; + set(value: T, options?: SpringUpdateOptions): Promise; damping: number; precision: number; @@ -2085,8 +2119,8 @@ declare module 'svelte/motion' { } export interface Tweened extends Readable { - set(value: T, opts?: TweenedOptions): Promise; - update(updater: Updater, opts?: TweenedOptions): Promise; + set(value: T, opts?: TweenOptions): Promise; + update(updater: Updater, opts?: TweenOptions): Promise; } /** Callback to inform of a value updates. */ type Subscriber = (value: T) => void; @@ -2103,39 +2137,6 @@ declare module 'svelte/motion' { */ subscribe(this: void, run: Subscriber, invalidate?: () => void): Unsubscriber; } - interface SpringOpts { - stiffness?: number; - damping?: number; - precision?: number; - } - - interface SpringUpdateOpts { - /** - * @deprecated Only use this for the spring store; does nothing when set on the Spring class - */ - hard?: any; - /** - * @deprecated Only use this for the spring store; does nothing when set on the Spring class - */ - soft?: string | number | boolean; - /** - * Only use this for the Spring class; does nothing when set on the spring store - */ - instant?: boolean; - /** - * Only use this for the Spring class; does nothing when set on the spring store - */ - preserveMomentum?: number; - } - - type Updater = (target_value: T, value: T) => T; - - interface TweenedOptions { - delay?: number; - duration?: number | ((from: T, to: T) => number); - easing?: (t: number) => number; - interpolate?: (a: T, b: T) => (t: number) => T; - } /** * A [media query](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery) that matches if the user [prefers reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). * @@ -2165,13 +2166,13 @@ declare module 'svelte/motion' { * * @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead * */ - export function spring(value?: T | undefined, opts?: SpringOpts | undefined): Spring; + export function spring(value?: T | undefined, opts?: SpringOptions | undefined): Spring; /** * A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time. * * @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead * */ - export function tweened(value?: T | undefined, defaults?: TweenedOptions | undefined): Tweened; + export function tweened(value?: T | undefined, defaults?: TweenOptions | undefined): Tweened; /** * A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to * move towards it over time, taking account of the `delay`, `duration` and `easing` options. @@ -2204,15 +2205,15 @@ declare module 'svelte/motion' { * ``` * */ - static of(fn: () => U, options?: TweenedOptions | undefined): Tween; + static of(fn: () => U, options?: TweenOptions | undefined): Tween; - constructor(value: T, options?: TweenedOptions); + constructor(value: T, options?: TweenOptions); /** * Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it. * * If `options` are provided, they will override the tween's defaults. * */ - set(value: T, options?: TweenedOptions | undefined): Promise; + set(value: T, options?: TweenOptions | undefined): Promise; get current(): T; set target(v: T); get target(): T; From b90a5dfb61545a547f7c5891fc23052c5ff251ea Mon Sep 17 00:00:00 2001 From: Razin Shafayet Date: Mon, 23 Mar 2026 00:29:08 +0600 Subject: [PATCH 10/41] docs: clarify `$derived` `await` behavior and dependency tracking (#17696) Closes #17660 ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Rich Harris --- documentation/docs/02-runes/03-$derived.md | 11 ++++++++++ .../19-await-expressions.md | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/documentation/docs/02-runes/03-$derived.md b/documentation/docs/02-runes/03-$derived.md index 35cb6c1912..f85ba90baa 100644 --- a/documentation/docs/02-runes/03-$derived.md +++ b/documentation/docs/02-runes/03-$derived.md @@ -51,6 +51,17 @@ In essence, `$derived(expression)` is equivalent to `$derived.by(() => expressio Anything read synchronously inside the `$derived` expression (or `$derived.by` function body) is considered a _dependency_ of the derived state. When the state changes, the derived will be marked as _dirty_ and recalculated when it is next read. +In addition, if an expression contains an [`await`](await-expressions), Svelte transforms it such that any state _after_ the `await` is also tracked — in other words, in a case like this... + +```js +let a = Promise.resolve(1); +let b = 2; +// ---cut--- +let total = $derived(await a + b); +``` + +...both `a` and `b` are tracked, even though `b` is only read once `a` has resolved, after the initial execution. (This does not apply to `await` in functions that are called by the expression, only the expression itself.) + To exempt a piece of state from being treated as a dependency, use [`untrack`](svelte#untrack). ## Overriding derived values diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md index 2f73f6a47c..aa3f907b14 100644 --- a/documentation/docs/03-template-syntax/19-await-expressions.md +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -59,8 +59,8 @@ Updates can overlap — a fast update will be reflected in the UI while an earli Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup... ```svelte -

{await one()}

-

{await two()}

+

{await one(x)}

+

{await two(y)}

``` ...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential. @@ -68,13 +68,18 @@ Svelte will do as much asynchronous work as it can in parallel. For example if y This does not apply to sequential `await` expressions inside your ` + +a {await delay(a)} | b {await delay(b)} | c {c} | d {d} + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js new file mode 100644 index 0000000000..f4aff83aad --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js @@ -0,0 +1,82 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + await tick(); + const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + a_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second b resolved, blocked on first batch because a still pending + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + for (let i = 0; i < 3; i++) { + pop.click(); // second a resolved, first a/b now obsolete; empty queue + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 2 | b 2 | c 1 | d 1 + + + + + + ` + ); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte new file mode 100644 index 0000000000..25dbe586ca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/main.svelte @@ -0,0 +1,26 @@ + + +a {await delay(a)} | b {await delay(b)} | c {c} | d {d} + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js new file mode 100644 index 0000000000..c9e7513b22 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/_config.js @@ -0,0 +1,108 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + await tick(); + const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + a_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // first a resolved, still pending: [b, a, b] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second b resolved, still pending: [b, a] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // first b resolved, first + last batch settled, still pending: [a] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 0 | d 1 + + + + + + ` + ); + + shift.click(); // all resolved + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 2 | b 2 | c 1 | d 1 + + + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte new file mode 100644 index 0000000000..25dbe586ca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-3/main.svelte @@ -0,0 +1,26 @@ + + +a {await delay(a)} | b {await delay(b)} | c {c} | d {d} + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js new file mode 100644 index 0000000000..472a3ebcaf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/_config.js @@ -0,0 +1,110 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + await tick(); + const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + a_c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + b_d.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // first a resolved, still pending: [b, a, b] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second b resolved, still pending: [b, a] + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + pop.click(); // second a resolved, first a/b now obsolete + // TODO would be nice to show final result here already, right now it doesn't because + // we have no handle on the already resolved first a anymore + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + + ` + ); + + shift.click(); // queue empty + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 2 | b 2 | c 1 | d 1 + + + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte new file mode 100644 index 0000000000..25dbe586ca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-4/main.svelte @@ -0,0 +1,26 @@ + + +a {await delay(a)} | b {await delay(b)} | c {c} | d {d} + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js new file mode 100644 index 0000000000..d03f3cfbb8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js @@ -0,0 +1,118 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, c, shift, pop] = target.querySelectorAll('button'); + + a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + // how it's on main + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 1 | d 1 + + + + + ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + + // how it's on https://github.com/sveltejs/svelte/pull/17971 + // shift.click(); + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 0 | b 0 | c 0 | d 0 + // + // + // + // + // ` + // ); + + // shift.click(); + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 1 | b 2 | c 0 | d 2 + // + // + // + // + // ` + // ); + + // shift.click(); + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 1 | b 2 | c 1 | d 3 + // + // + // + // + // ` + // ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte new file mode 100644 index 0000000000..cf028718c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/main.svelte @@ -0,0 +1,23 @@ + + +a {a} | b {b} | c {c} | d {d} + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js new file mode 100644 index 0000000000..27152889a2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/_config.js @@ -0,0 +1,76 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, c, shift, pop] = target.querySelectorAll('button'); + + a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + // Although the second batch is eventually connected to the first one, we can't see that + // at this point yet and so the second one flushes right away. + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 1 | d 1 + + + + + ` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 1 | d 1 + + + + + ` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte new file mode 100644 index 0000000000..cf028718c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-6/main.svelte @@ -0,0 +1,23 @@ + + +a {a} | b {b} | c {c} | d {d} + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js new file mode 100644 index 0000000000..b1bf53a2fc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js @@ -0,0 +1,129 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, c, shift, pop] = target.querySelectorAll('button'); + + a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + c.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + shift.click(); // schedules second step of first batch and schedules rerun of second batch + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 0 | b 0 | c 0 | d 0 + + + + + ` + ); + + // how it's on main + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 0 | d 2 + + + + + ` + ); + + shift.click(); // obsolete second batch promise (already rejected) + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 0 | d 2 + + + + + ` + ); + + shift.click(); // first batch resolves + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + a 1 | b 2 | c 1 | d 3 + + + + + ` + ); + + // how it's on https://github.com/sveltejs/svelte/pull/17971 + // pop.click(); // second batch resolves but knows it needs to wait on first batch + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 0 | b 0 | c 0 | d 0 + // + // + // + // + // ` + // ); + + // shift.click(); // obsolete second batch promise (already rejected) + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 0 | b 0 | c 0 | d 0 + // + // + // + // + // ` + // ); + + // shift.click(); // first batch resolves, with it second can now resolve as well + // await tick(); + // assert.htmlEqual( + // target.innerHTML, + // ` + // a 1 | b 2 | c 1 | d 3 + // + // + // + // + // ` + // ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte new file mode 100644 index 0000000000..cf028718c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/main.svelte @@ -0,0 +1,23 @@ + + +a {a} | b {b} | c {c} | d {d} + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js new file mode 100644 index 0000000000..07ce7e340f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b_fork, a_c, shift, pop, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + a_b_fork.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0'); + + a_c.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0'); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1'); + + commit.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte new file mode 100644 index 0000000000..60ba74fb8e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-1/main.svelte @@ -0,0 +1,27 @@ + + +

a {await delay(a)} | b {await delay(b)} | c {c}

+ + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js new file mode 100644 index 0000000000..180a190dae --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + a_b_fork.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + a_c.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + b_d.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1'); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + + commit.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte new file mode 100644 index 0000000000..6d1c4ab418 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/main.svelte @@ -0,0 +1,31 @@ + + +

a {await delay(a)} | b {await delay(b)} | c {c} | d {d}

+ + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js new file mode 100644 index 0000000000..61360d8dc9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, + async test({ assert, target }) { + await tick(); + const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + a_b_fork.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + a_c.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + b_d.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 0 | b 0 | c 0 | d 0'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 0 | c 1 | d 0'); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + + commit.click(); + await tick(); + assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte new file mode 100644 index 0000000000..6d1c4ab418 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/main.svelte @@ -0,0 +1,31 @@ + + +

a {await delay(a)} | b {await delay(b)} | c {c} | d {d}

+ + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte new file mode 100644 index 0000000000..6b765526c8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/Child.svelte @@ -0,0 +1,7 @@ + + +{x} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js new file mode 100644 index 0000000000..0af275009c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target, logs }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` // if this shows world world - that would also be ok + ); + + resolve.click(); + await tick(); + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); + assert.htmlEqual( + target.innerHTML, + ` + + + + universe + universe + universe + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte new file mode 100644 index 0000000000..8edc718de2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/main.svelte @@ -0,0 +1,28 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js new file mode 100644 index 0000000000..035616dfb6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js @@ -0,0 +1,49 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
+ ` // if this shows world world "world" world world world "world" - then this would also be ok + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte new file mode 100644 index 0000000000..11c4bdf5d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/main.svelte @@ -0,0 +1,30 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js new file mode 100644 index 0000000000..a2d615b6e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js @@ -0,0 +1,61 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
+ ` // if this shows world world "world" world world world "world" - then this would also be ok + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte new file mode 100644 index 0000000000..b02ab20995 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/main.svelte @@ -0,0 +1,34 @@ + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js new file mode 100644 index 0000000000..8b7e7784de --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js @@ -0,0 +1,171 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO more combinations pass on https://github.com/sveltejs/svelte/pull/17971 + timeout: 20_000, + async test({ assert, target }) { + const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] = + target.querySelectorAll('button'); + + const initial = ` + + + + + + + + + +
+ `; + + const final = ` + + + + + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + `; + + /** @param {HTMLElement} button */ + async function click(button) { + button.click(); + await tick(); + } + + /** + * Generate all permutations of an array. + * @param {HTMLElement[]} actions + * @returns {HTMLElement[][]} + */ + function permutations(actions) { + if (actions.length <= 1) return [actions]; + + /** @type {HTMLElement[][]} */ + const result = []; + + for (let i = 0; i < actions.length; i++) { + const head = actions[i]; + const rest = actions.slice(0, i).concat(actions.slice(i + 1)); + for (const tail of permutations(rest)) { + result.push([head, ...tail]); + } + } + + return result; + } + + /** + * Keep only valid orders where fork commits happen after their fork action. + * @param {HTMLElement[]} order + */ + function is_valid_order(order) { + const x_fork_index = order.indexOf(fork_x); + const commit_x_index = order.indexOf(commit_x); + if (commit_x_index !== -1 && (x_fork_index === -1 || commit_x_index < x_fork_index)) { + return false; + } + + const y_fork_index = order.indexOf(fork_y); + const commit_y_index = order.indexOf(commit_y); + if (commit_y_index !== -1 && (y_fork_index === -1 || commit_y_index < y_fork_index)) { + return false; + } + + return true; + } + + /** + * Four control scenarios: + * - x direct, y direct + * - x direct, y via fork+commit + * - x via fork+commit, y direct + * - x via fork+commit, y via fork+commit + */ + const control_scenarios = [ + [x, y], + [x, fork_y, commit_y], + [fork_x, commit_x, y], + [fork_x, commit_x, fork_y, commit_y] + ]; + + const control_orders = control_scenarios.flatMap((scenario) => + permutations(scenario).filter(is_valid_order) + ); + + /** + * All shift/pop combinations for draining async work. + * We click three times because this scenario can queue up to 3 deferred resolutions. + */ + const resolve_orders = [ + [shift, shift, shift], + [shift, pop, pop], + [pop, shift, shift], + [pop, pop, pop] + ]; + + for (const controls of control_orders) { + for (const resolves of resolve_orders) { + for (const action of controls) { + await click(action); + } + + for (const action of resolves) { + await click(action); + } + + const failure_msg = `Failed for: ${controls + .map((btn) => btn.textContent) + .concat(...resolves.map((btn) => btn.textContent)) + .join(', ')}`; + assert.htmlEqual(target.innerHTML, final, failure_msg); + + await click(reset); + assert.htmlEqual(target.innerHTML, initial, failure_msg); + } + } + + const other_scenarios = [ + [x, shift, y, shift, shift], + [x, shift, y, pop, pop], + [fork_x, shift, y, shift, commit_x, shift], + [fork_x, shift, y, pop, commit_x, pop], + [y, shift, x, shift, shift], + [y, shift, x, pop, pop], + [fork_y, shift, x, shift, commit_y, shift], + [fork_y, shift, x, pop, commit_y, pop] + ]; + + for (const scenario of other_scenarios) { + for (const action of scenario) { + await click(action); + } + + const failure_msg = `Failed for: ${scenario.map((btn) => btn.textContent).join(', ')}`; + assert.htmlEqual(target.innerHTML, final, failure_msg); + + await click(reset); + assert.htmlEqual(target.innerHTML, initial, failure_msg); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte new file mode 100644 index 0000000000..bb821d9b0f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/main.svelte @@ -0,0 +1,41 @@ + + + + + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js new file mode 100644 index 0000000000..737169cb91 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js @@ -0,0 +1,90 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, shift, pop, commit] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte new file mode 100644 index 0000000000..5f8d5efa13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js new file mode 100644 index 0000000000..a712e70630 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/_config.js @@ -0,0 +1,71 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, shift, pop, commit] = target.querySelectorAll('button'); + + y.click(); + await tick(); + + x.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte new file mode 100644 index 0000000000..5f8d5efa13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-2/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js new file mode 100644 index 0000000000..905e76511b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/_config.js @@ -0,0 +1,70 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [x, y, shift, pop, commit] = target.querySelectorAll('button'); + + y.click(); + await tick(); + + x.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + +
+ ` + ); + + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte new file mode 100644 index 0000000000..5f8d5efa13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-3/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js new file mode 100644 index 0000000000..6a6399a19e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js @@ -0,0 +1,76 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, resolve, commit] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte new file mode 100644 index 0000000000..5f00fc4dec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/main.svelte @@ -0,0 +1,32 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte new file mode 100644 index 0000000000..f8c01e9efd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/Child.svelte @@ -0,0 +1,11 @@ + + + +{x} +{JSON.stringify(x)} +{#if x === 'universe'}universe{:else}world{/if} +{#if JSON.stringify(x) === '"universe"'}universe{:else}world{/if} +{await Promise.resolve(x)} +{await Promise.resolve(JSON.stringify(x))} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js new file mode 100644 index 0000000000..9221a96c2e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js @@ -0,0 +1,85 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 + async test({ assert, target }) { + const [x, y, resolve, commit] = target.querySelectorAll('button'); + + x.click(); + await tick(); + + y.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +
+ world + "world" + world + world + world + "world" + ` + ); + + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + + universe + universe + "universe" + universe + universe + universe + "universe" +
+ universe + "universe" + universe + universe + universe + "universe" + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte new file mode 100644 index 0000000000..5575e3cbd4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + +{#if x === 'universe'} + {await delay(x)} + +{/if} + +
+ +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts index bbd252b8e1..af36f16e29 100644 --- a/packages/svelte/tests/suite.ts +++ b/packages/svelte/tests/suite.ts @@ -4,6 +4,7 @@ import { it } from 'vitest'; export interface BaseTest { skip?: boolean; solo?: boolean; + timeout?: number; } /** @@ -30,7 +31,7 @@ export function suite(fn: (config: Test, test_dir: string await for_each_dir(cwd, samples_dir, (config, dir) => { let it_fn = config.skip ? it.skip : config.solo ? it.only : it; - it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); + it_fn(dir, { timeout: config.timeout }, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); }); } }; @@ -57,7 +58,7 @@ export function suite_with_variants { + it_fn(`${dir} (${variant})`, { timeout: config.timeout }, async () => { if (!called_common) { called_common = true; common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`); From 54ba176d2c9068f2e6df9249764bb7766ec1b69d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 24 Mar 2026 11:31:17 -0400 Subject: [PATCH 14/41] docs: 'open in playground' links (#17983) This takes advantage of https://github.com/sveltejs/svelte.dev/pull/1879 and https://github.com/sveltejs/svelte.dev/pull/1880 to replace some of the hardcoded playground links in the docs with ones that are generated automatically from the code. This is a better user experience, and easier to maintain. Not every playground link is suitable for replacement, as in many cases we gloss over implementation details. For now I'm just starting with some easy ones --- documentation/docs/02-runes/04-$effect.md | 49 ++++++- documentation/docs/02-runes/05-$props.md | 25 +++- documentation/docs/02-runes/07-$inspect.md | 10 +- .../docs/03-template-syntax/03-each.md | 19 ++- .../docs/03-template-syntax/06-snippet.md | 128 ++++++++++++++++-- .../docs/03-template-syntax/12-bind.md | 22 ++- .../19-await-expressions.md | 5 +- documentation/docs/06-runtime/02-context.md | 2 +- 8 files changed, 235 insertions(+), 25 deletions(-) diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md index d41c5b8e6a..a13fc7bc46 100644 --- a/documentation/docs/02-runes/04-$effect.md +++ b/documentation/docs/02-runes/04-$effect.md @@ -41,9 +41,11 @@ You can use `$effect` anywhere, not just at the top level of a component, as lon > [!NOTE] Svelte uses effects internally to represent logic and expressions in your template — this is how `

hello {name}!

` updates when `name` changes. -An effect can return a _teardown function_ which will run immediately before the effect re-runs ([demo](/playground/untitled#H4sIAAAAAAAAE42SQVODMBCF_8pOxkPRKq3HCsx49K4n64xpskjGkDDJ0tph-O8uINo6HjxB3u7HvrehE07WKDbiyZEhi1osRWksRrF57gQdm6E2CKx_dd43zU3co6VB28mIf-nKO0JH_BmRRRVMQ8XWbXkAgfKtI8jhIpIkXKySu7lSG2tNRGZ1_GlYr1ZTD3ddYFmiosUigbyAbpC2lKbwWJkIB8ZhhxBQBWRSw6FCh3sM8GrYTthL-wqqku4N44TyqEgwF3lmRHr4Op0PGXoH31c5rO8mqV-eOZ49bikgtcHBL55tmhIkEMqg_cFB2TpFxjtg703we6NRL8HQFCS07oSUCZi6Rm04lz1yytIHBKoQpo1w6Gsm4gmyS8b8Y5PydeMdX8gwS2Ok4I-ov5NZtvQde95GMsccn_1wzNKfu3RZtS66cSl9lvL7qO1aIk7knbJGvefdtIOzi73M4bYvovUHDFk6AcX_0HRESxnpBOW_jfCDxIZCi_1L_wm4xGQ60wIAAA==)). +An effect can return a _teardown function_ which will run immediately before the effect re-runs: + ```svelte + + @@ -236,6 +254,7 @@ When using [`await`](await-expressions) in components, the `$effect.pending()` r

pending promises: {$effect.pending()}

{/if} ``` + ## `$effect.root` @@ -285,9 +304,11 @@ In general, `$effect` is best considered something of an escape hatch — useful If you're using an effect because you want to be able to reassign the derived value (to build an optimistic UI, for example) note that [deriveds can be directly overridden]($derived#Overriding-derived-values) as of Svelte 5.25. -You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Don't use effects for this ([demo](/playground/untitled#H4sIAAAAAAAAE5WRTWrDMBCFryKGLBJoY3fRjWIHeoiu6i6UZBwEY0VE49TB-O6VxrFTSih0qe_Ne_OjHpxpEDS8O7ZMeIAnqC1hAP3RA1990hKI_Fb55v06XJA4sZ0J-IjvT47RcYyBIuzP1vO2chVHHFjxiQ2pUr3k-SZRQlbBx_LIFoEN4zJfzQph_UMQr4hRXmBd456Xy5Uqt6pPKHmkfmzyPAZL2PCnbRpg8qWYu63I7lu4gswOSRYqrPNt3CgeqqzgbNwRK1A76w76YqjFspfcQTWmK3vJHlQm1puSTVSeqdOc_r9GaeCHfUSY26TXry6Br4RSK3C6yMEGT-aqVU3YbUZ2NF6rfP2KzXgbuYzY46czdgyazy0On_FlLH3F-UDXhgIO35UGlA1rAgAA)): +You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Instead of using effects for this... + ```svelte + + + +``` ```svelte @@ -163,6 +179,7 @@ The fallback value of a prop not declared with `$bindable` is left untouched — clicks: {object.count} ``` + In summary: don't mutate props. Either use callback props to communicate changes, or — if parent and child should share the same object — use the [`$bindable`]($bindable) rune. diff --git a/documentation/docs/02-runes/07-$inspect.md b/documentation/docs/02-runes/07-$inspect.md index f67e250b45..00857f3ef4 100644 --- a/documentation/docs/02-runes/07-$inspect.md +++ b/documentation/docs/02-runes/07-$inspect.md @@ -5,9 +5,11 @@ tags: rune-inspect > [!NOTE] `$inspect` only works during development. In a production build it becomes a noop. -The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire ([demo](/playground/untitled#H4sIAAAAAAAACkWQ0YqDQAxFfyUMhSotdZ-tCvu431AXtGOqQ2NmmMm0LOK_r7Utfby5JzeXTOpiCIPKT5PidkSVq2_n1F7Jn3uIcEMSXHSw0evHpAjaGydVzbUQCmgbWaCETZBWMPlKj29nxBDaHj_edkAiu12JhdkYDg61JGvE_s2nR8gyuBuiJZuDJTyQ7eE-IEOzog1YD80Lb0APLfdYc5F9qnFxjiKWwbImo6_llKRQVs-2u91c_bD2OCJLkT3JZasw7KLA2XCX31qKWE6vIzNk1fKE0XbmYrBTufiI8-_8D2cUWBA_AQAA)): +The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire: + ```svelte + @@ -71,6 +73,7 @@ Snippets can be declared anywhere inside your component. They can reference valu {@render hello('alice')} {@render hello('bob')} ``` + ...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings): @@ -91,9 +94,11 @@ Snippets can be declared anywhere inside your component. They can reference valu {@render x()} ``` -Snippets can reference themselves and each other ([demo](/playground/untitled#H4sIAAAAAAAAE2WPTQqDMBCFrxLiRqH1Zysi7TlqF1YnENBJSGJLCYGeo5tesUeosfYH3c2bee_jjaWMd6BpfrAU6x5oTvdS0g01V-mFPkNnYNRaDKrxGxto5FKCIaeu1kYwFkauwsoUWtZYPh_3W5FMY4U2mb3egL9kIwY0rbhgiO-sDTgjSEqSTvIDs-jiOP7i_MHuFGAL6p9BtiSbOTl0GtzCuihqE87cqtyam6WRGz_vRcsZh5bmRg3gju4Fptq_kzQBAAA=)): +Snippets can reference themselves and each other: + ```svelte + {#snippet blastoff()} 🚀 {/snippet} @@ -109,14 +114,17 @@ Snippets can reference themselves and each other ([demo](/playground/untitled#H4 {@render countdown(10)} ``` + ## Passing snippets to components ### Explicit props -Within the template, snippets are values just like any other. As such, they can be passed to components as props ([demo](/playground/untitled#H4sIAAAAAAAAE3VS247aMBD9lZGpBGwDASRegonaPvQL2qdlH5zYEKvBNvbQLbL875VzAcKyj3PmzJnLGU8UOwqSkd8KJdaCk4TsZS0cyV49wYuJuQiQpGd-N2bu_ooaI1YwJ57hpVYoFDqSEepKKw3mO7VDeTTaIvxiRS1gb_URxvO0ibrS8WanIrHUyiHs7Vmigy28RmyHHmKvDMbMmFq4cQInvGSwTsBYWYoMVhCSB2rBFFPsyl0uruTlR3JZCWvlTXl1Yy_mawiR_rbZKZrellJ-5JQ0RiBUgnFhJ9OGR7HKmwVoilXeIye8DOJGfYCgRlZ3iE876TBsZPX7hPdteO75PC4QaIo8vwNPePmANQ2fMeEFHrLD7rR1jTNkW986E8C3KwfwVr8HSHOSEBT_kGRozyIkn_zQveXDL3rIfPJHtUDwzShJd_Qk3gQCbOGLsdq4yfTRJopRuin3I7nv6kL7ARRjmLdBDG3uv1mhuLA3V2mKtqNEf_oCn8p9aN-WYqH5peP4kWBl1UwJzAEPT9U7K--0fRrrWnPTXpCm1_EVdXjpNmlA8G1hPPyM1fKgMqjFHjctXGjLhZ05w0qpDhksGrybuNEHtJnCalZWsuaTlfq6nPaaBSv_HKw-K57BjzOiVj9ZKQYKzQjZodYFqydYTRN4gPhVzTDO2xnma3HsVWjaLjT8nbfwHy7Q5f2dBAAA)): +Within the template, snippets are values just like any other. As such, they can be passed to components as props: + ```svelte + + + + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
+ + ``` + Think about it like passing content instead of data to a component. The concept is similar to slots in web components. ### Implicit props -As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component ([demo](/playground/untitled#H4sIAAAAAAAAE3VSTa_aMBD8Kyu_SkAbCA-JSzBR20N_QXt6vIMTO8SqsY29tI2s_PcqTiB8vaPHs7MzuxuIZgdBMvJLo0QlOElIJZXwJHsLBBvb_XUASc7Mb9Yu_B-hsMMK5sUzvDQahUZPMkJ96aTFfKd3KA_WOISfrFACKmcOMFmk8TWUTjY73RFLoz1C5U4SPWzhrcN2GKDrlcGEWauEnyRwxCaDdQLWyVJksII2uaMWTDPNLtzX5YX8-kgua-GcHJVXI3u5WEPb0d83O03TMZSmfRzOkG1Db7mNacOL19JagVALxoWbztq-H8U6j0SaYp2P2BGbOyQ2v8PQIFMXLKRDk177pq0zf6d8bMrzwBdd0pamyPMb-IjNEzS2f86Gz_Dwf-2F9nvNSUJQ_EOSoTuJNvngqK5v4Pas7n4-OCwlEEJcQTIMO-nSQwtb-GSdsX46e9gbRoP9yGQ11I0rEuycunu6PHx1QnPhxm3SFN15MOlYEFJZtf0dUywMbwZOeBGsrKNLYB54-1R9WNqVdki7usim6VmQphf7mnpshiQRhNAXdoOfMyX3OgMlKtz0cGEcF27uLSul3mewjPjgOOoDukxjPS9rqfh0pb-8zs6aBSt_7505aZ7B9xOi0T9YKW4UooVsr0zB1BTrWQJ3EL-oWcZ572GxFoezCk37QLe3897-B2i2U62uBAAA)): +As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component: + ```svelte - + + + {#snippet header()} @@ -169,12 +225,54 @@ As an authoring convenience, snippets declared directly _inside_ a component imp
fruit
``` +```svelte + + + + + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
+ + +``` + + ### Implicit `children` snippet -Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet ([demo](/playground/untitled#H4sIAAAAAAAAE3WOQQrCMBBFrzIMggql3ddY1Du4si5sOmIwnYRkFKX07lKqglqX8_7_w2uRDw1hjlsWI5ZqTPBoLEXMdy3K3fdZDzB5Ndfep_FKVnpWHSKNce1YiCVijirqYLwUJQOYxrsgsLmIOIZjcA1M02w4n-PpomSVvTclqyEutDX6DA2pZ7_ABIVugrmEC3XJH92P55_G39GodCmWBFrQJ2PrQAwdLGHig_NxNv9xrQa1dhWIawrv1Wzeqawa8953D-8QOmaEAQAA)): +Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet: + ```svelte + + ``` @@ -187,6 +285,7 @@ Any content inside the component tags that is _not_ a snippet declaration implic ``` + > [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name @@ -256,9 +355,21 @@ We can tighten things up further by declaring a generic, so that `data` and `row ## Exporting snippets -Snippets declared at the top level of a `.svelte` file can be exported from a ` + +{@render add(1, 2)} + +``` ```svelte + @@ -267,6 +378,7 @@ Snippets declared at the top level of a `.svelte` file can be exported from a `< {a} + {b} = {a + b} {/snippet} ``` + > [!NOTE] > This requires Svelte 5.5.0 or newer diff --git a/documentation/docs/03-template-syntax/12-bind.md b/documentation/docs/03-template-syntax/12-bind.md index be84969b87..e8164149db 100644 --- a/documentation/docs/03-template-syntax/12-bind.md +++ b/documentation/docs/03-template-syntax/12-bind.md @@ -54,9 +54,11 @@ A `bind:value` directive on an `` element binds the input's `value` prope

{message}

``` -In the case of a numeric input (`type="number"` or `type="range"`), the value will be coerced to a number ([demo](/playground/untitled#H4sIAAAAAAAAE6WPwYoCMQxAfyWEPeyiOOqx2w74Hds9pBql0IllmhGXYf5dKqwiyILsLXnwwsuI-5i4oPkaUX8yo7kCnKNQV7dNzoty4qSVBSr8jG-Poixa0KAt2z5mbb14TaxA4OCtKCm_rz4-f2m403WltrlrYhMFTtcLNkoeFGqZ8yhDF7j3CCHKzpwoDexGmqCL4jwuPUJHZ-dxVcfmyYGe5MAv-La5pbxYFf5Z9Zf_UJXb-sEMquFgJJhBmGyTW5yj8lnRaD_w9D1dAKSSj7zqAQAA)): +In the case of a numeric input (`type="number"` or `type="range"`), the value will be coerced to a number: + ```svelte + +

Customize your burrito

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

Tortilla: {tortilla}

+

Fillings: {fillings.join(', ') || 'None'}

+ + ``` + > [!NOTE] `bind:group` only works if the inputs are in the same Svelte component. diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md index aa3f907b14..04437f5ee3 100644 --- a/documentation/docs/03-template-syntax/19-await-expressions.md +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -25,9 +25,11 @@ The experimental flag will be removed in Svelte 6. ## Synchronized updates -When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like [this](/playground/untitled#H4sIAAAAAAAAE42QsWrDQBBEf2VZUkhYRE4gjSwJ0qVMkS6XYk9awcFpJe5Wdoy4fw-ycdykSPt2dpiZFYVGxgrf2PsJTlPwPWTcO-U-xwIH5zli9bminudNtwEsbl-v8_wYj-x1Y5Yi_8W7SZRFI1ZYxy64WVsjRj0rEDTwEJWUs6f8cKP2Tp8vVIxSPEsHwyKdukmA-j6jAmwO63Y1SidyCsIneA_T6CJn2ZBD00Jk_XAjT4tmQwEv-32eH6AsgYK6wXWOPPTs6Xy1CaxLECDYgb3kSUbq8p5aaifzorCt0RiUZbQcDIJ10ldH8gs3K6X2Xzqbro5zu1KCHaw2QQPrtclvwVSXc2sEC1T-Vqw0LJy-ClRy_uSkx2ogHzn9ADZ1CubKAQAA)... +When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like this... + ```svelte + + +
+
{@html await firstTest()}
+ {await otherTest()} +
From a9d8439ad1eecde5c4a91dca1eb1f034abfe7d81 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:02:04 +0100 Subject: [PATCH 16/41] 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 17/41] 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 18/41] 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 19/41] 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 27/41] 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 28/41] 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 29/41] 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 30/41] 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 31/41] 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 32/41] 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 33/41] 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 34/41] 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 35/41] 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 36/41] 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 37/41] 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 38/41] 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 39/41] 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 40/41] 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 41/41] 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} + + +