From 89b6a939fe40ac657f27219d29c601fa67202eed Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Tue, 5 May 2026 20:48:50 +0200 Subject: [PATCH 01/32] fix: wrap `Promise.all` in `save` during SSR (#18178) Closes #18168 Not sure if there's a deeper issue in play because the error it's only really there if you also add a title to the component. I think the issue is that with multiple arguments the top level `Promise.all` is not wrapped in `save` and that probably causes a race condition with `title` that sets the context back to `null` in a `finally`. One issue is that now the generated code looks like this ```js const [$$0, $$1] = (await $.save(Promise.all([ (async () => (await $.save(user()))().name)(), (async () => (await $.save(user()))().image)() ])))(); ``` which seems a bit redundant, but I'm not sure if we can get rid of the inner `save` since they are indeed awaiting something. --- .changeset/tough-knives-smell.md | 5 +++++ .../3-transform/server/visitors/shared/utils.js | 4 ++-- .../samples/async-multiple-attrs/_config.js | 8 ++++++++ .../samples/async-multiple-attrs/_expected.html | 1 + .../async-multiple-attrs/_expected_head.html | 1 + .../samples/async-multiple-attrs/main.svelte | 15 +++++++++++++++ 6 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/tough-knives-smell.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte diff --git a/.changeset/tough-knives-smell.md b/.changeset/tough-knives-smell.md new file mode 100644 index 0000000000..7687188c1a --- /dev/null +++ b/.changeset/tough-knives-smell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: wrap `Promise.all` in `save` during SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index a87642bc4c..9b3ac3ad78 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -12,7 +12,7 @@ import { import * as b from '#compiler/builders'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_whitespaces_strict } from '../../../../patterns.js'; -import { has_await_expression } from '../../../../../utils/ast.js'; +import { has_await_expression, save } from '../../../../../utils/ast.js'; import { ExpressionMetadata } from '../../../../nodes.js'; /** Opens an if/each block, so that we can remove nodes in the case of a mismatch */ @@ -360,7 +360,7 @@ export class PromiseOptimiser { return b.const( b.array_pattern(this.expressions.map((_, i) => b.id(`$$${i}`))), - b.await(b.call('Promise.all', promises)) + save(b.call('Promise.all', promises)) ); } diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js new file mode 100644 index 0000000000..aaf40e7a52 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + compileOptions: { + dev: true + } +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html new file mode 100644 index 0000000000..93016d569c --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html new file mode 100644 index 0000000000..96ba2bba28 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html @@ -0,0 +1 @@ +Async multiple attributes \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte new file mode 100644 index 0000000000..f14ccd088b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte @@ -0,0 +1,15 @@ + + + +Async multiple attributes + + +{(await From d4c5a917356a4ef0905681bdd98113c84707db42 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 09:59:39 +0200 Subject: [PATCH 02/32] fix: rethrow error of failed iterable after calling `return()` (#18169) The fix in #17966 wasn't quite right, because we gotta rethrow in case the iterator stopped because of an error. Fixes part of the SvelteKit `query.live` test failure. --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Rich Harris --- .changeset/fresh-stars-grin.md | 5 +++ .../src/internal/client/reactivity/async.js | 25 ++++++++--- .../_config.js | 24 ++++++++++ .../main.svelte | 45 +++++++++++++++++++ .../_config.js | 21 +++++++++ .../main.svelte | 43 ++++++++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 .changeset/fresh-stars-grin.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte diff --git a/.changeset/fresh-stars-grin.md b/.changeset/fresh-stars-grin.md new file mode 100644 index 0000000000..3d56792d1e --- /dev/null +++ b/.changeset/fresh-stars-grin.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: rethrow error of failed iterable after calling `return()` diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 6aea790c36..61fff31f8a 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -213,22 +213,35 @@ export async function* for_await_track_reactivity_loss(iterable) { throw new TypeError('value is not async iterable'); } - /** Whether the completion of the iterator was "normal", meaning it wasn't ended via `break` or a similar method */ - let normal_completion = false; + // eslint-disable-next-line no-useless-assignment + let invoke_return = true; + try { while (true) { const { done, value } = (await track_reactivity_loss(iterator.next()))(); if (done) { - normal_completion = true; + invoke_return = false; break; } var prev = reactivity_loss_tracker; - yield value; + try { + yield value; + } catch (e) { + set_reactivity_loss_tracker(prev); + // If the yield throws, we need to call `return` but not return its value, instead rethrow + if (iterator.return !== undefined) { + (await track_reactivity_loss(iterator.return()))(); + } + throw e; + } set_reactivity_loss_tracker(prev); } + } catch (error) { + invoke_return = false; + throw error; } finally { - // 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) { + // If the iterator had an abrupt completion (break) and `return` is defined on the iterator, call it and return the value + if (invoke_return && 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-throws-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js new file mode 100644 index 0000000000..9785f639cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js @@ -0,0 +1,24 @@ +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 -> body failed -> ended

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

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js new file mode 100644 index 0000000000..9e8a2d8def --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_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 -> next failed -> ended

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

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
From 5e054574db2bd9f96176626a604046b6db13af09 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:02:57 +0200 Subject: [PATCH 03/32] fix: don't override new current_batch (#18170) This is a regression from #18117 - we moved `this.#commit()` higher up but that means that `current_batch` could be nulled out / overridden through `batch.activate/deactivate` / blocker runs inside `#commit()`. Therefore restore the previous value afterwards. No changest because #18117 is not released yet. Fixes the other part of the failing SvelteKit `query.live` test. --------- Co-authored-by: Rich Harris --- .../src/internal/client/reactivity/batch.js | 13 ++++++-- .../svelte/tests/runtime-legacy/shared.ts | 6 ++-- .../_config.js | 19 ++++++++++++ .../main.svelte | 30 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 4239cda04b..2801445ae7 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -92,6 +92,9 @@ let uid = 1; export class Batch { id = uid++; + /** True as soon as `#process()` was called */ + #started = false; + /** * 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) @@ -255,6 +258,8 @@ export class Batch { } #process() { + this.#started = true; + if (flush_count++ > 1000) { batches.delete(this); infinite_loop_guard(); @@ -342,6 +347,8 @@ export class Batch { this.#deferred?.resolve(); } + var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // 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 @@ -350,8 +357,6 @@ export class Batch { this.#commit(); } - var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); - // Edge case: During traversal new branches might create effects that run immediately and set state, // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // once more in that case - most of the time this will just clean up dirty branches. @@ -537,6 +542,8 @@ export class Batch { sources.push(source); } + if (!batch.#started) continue; + // Re-run async/block effects that depend on distinct values changed in both batches var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); @@ -722,7 +729,7 @@ export class Batch { if (!is_flushing_sync) { queue_micro_task(() => { - if (!batches.has(batch) || batch.#pending.size > 0) { + if (batch.#started) { // a flushSync happened in the meantime return; } diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 454ae2f766..6f30fb5d98 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -60,6 +60,8 @@ export interface RuntimeTest = Record void; after_test?: () => void; + /** If true, flushSync() will not be called before invoking test() */ + skip_initial_flushSync?: boolean; test?: (args: { variant: 'dom' | 'hydrate'; assert: Assert; @@ -505,7 +507,7 @@ async function run_test_variant( try { if (config.test) { - flushSync(); + if (!config.skip_initial_flushSync) flushSync(); if (variant === 'hydrate' && cwd.includes('async-')) { // wait for pending boundaries to render @@ -543,7 +545,7 @@ async function run_test_variant( } } finally { if (runes) { - unmount(instance); + await unmount(instance); } else { instance.$destroy(); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js new file mode 100644 index 0000000000..922feed515 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that batch.#commit() does not null out a potentially new current_batch +export default test({ + skip_initial_flushSync: true, // test that the initial batch is flushed without an explicit flushSync() call + async test({ assert, target }) { + await tick(); + + const [button] = target.querySelectorAll('button'); + const [updates] = target.querySelectorAll('p'); + + assert.htmlEqual(updates.innerHTML, 'false'); + + button.click(); + await tick(); + assert.htmlEqual(updates.innerHTML, 'true'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte new file mode 100644 index 0000000000..f7dae33b7e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte @@ -0,0 +1,30 @@ + + + + +

{updated}

+ + + {await new Promise(() => {})} + + {#snippet pending()} +

pending

+ {/snippet} +
From aeb6bd088b0f54d3847b244f7066333b28dead3f Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:04:50 +0200 Subject: [PATCH 04/32] fix: reapply context after transforming error during SSR (#18099) Don't have a test for it and no bug report but I stumbled upon this and I'm very certain not restoring context here is wrong since it means the failed snippet rendering gets the wrong context. --------- Co-authored-by: Rich Harris --- .changeset/cruel-boxes-serve.md | 5 +++++ packages/svelte/src/internal/server/renderer.js | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/cruel-boxes-serve.md diff --git a/.changeset/cruel-boxes-serve.md b/.changeset/cruel-boxes-serve.md new file mode 100644 index 0000000000..592cec4d01 --- /dev/null +++ b/.changeset/cruel-boxes-serve.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reapply context after transforming error during SSR diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index d2ab35a1f2..35aac64721 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -715,7 +715,12 @@ export class Renderer { const { context, failed, transformError } = item.#boundary; set_ssr_context(context); - let transformed = await transformError(error); + + let promise = transformError(error); + set_ssr_context(null); + + let transformed = await promise; + set_ssr_context(context); // Render the failed snippet instead of the partial children content const failed_renderer = new Renderer(item.global, item); From 91e1ead773c00eece56d49542c94670aabbc5902 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 6 May 2026 16:16:06 -0400 Subject: [PATCH 05/32] chore: remove unnecessary `increment_pending` calls (#18183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These might have been necessary at one point, but I'm confident they're unnecessary now — `increment_pending` happens (if necessary) inside `flatten`, which is called inside `async` and `deferred_template_effect`, so there's no need to call it inside those functions as well. --- .changeset/public-mammals-float.md | 5 +++++ .../svelte/src/internal/client/dom/blocks/async.js | 6 +----- .../svelte/src/internal/client/reactivity/effects.js | 10 +--------- 3 files changed, 7 insertions(+), 14 deletions(-) create mode 100644 .changeset/public-mammals-float.md diff --git a/.changeset/public-mammals-float.md b/.changeset/public-mammals-float.md new file mode 100644 index 0000000000..d890c9e070 --- /dev/null +++ b/.changeset/public-mammals-float.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: remove unnecessary `increment_pending` calls diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 43af3d8dd3..170529a6b9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,5 +1,5 @@ /** @import { Blocker, TemplateNode, Value } from '#client' */ -import { flatten, increment_pending } from '../../reactivity/async.js'; +import { flatten } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -42,8 +42,6 @@ export function async(node, blockers = [], expressions = [], fn) { return; } - const decrement_pending = increment_pending(); - if (was_hydrating) { var previous_hydrate_node = hydrate_node; set_hydrate_node(end); @@ -64,8 +62,6 @@ export function async(node, blockers = [], expressions = [], fn) { if (was_hydrating) { set_hydrating(false); } - - decrement_pending(); } }); } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 0fad074e6f..5bdba037b1 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -43,7 +43,7 @@ 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, current_batch } from './batch.js'; -import { flatten, increment_pending } from './async.js'; +import { flatten } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; @@ -396,16 +396,8 @@ export function template_effect(fn, sync = [], async = [], blockers = []) { * @param {Blocker[]} blockers */ export function deferred_template_effect(fn, sync = [], async = [], blockers = []) { - if (async.length > 0 || blockers.length > 0) { - var decrement_pending = increment_pending(); - } - flatten(blockers, sync, async, (values) => { create_effect(EFFECT, () => fn(...values.map(get))); - - if (decrement_pending) { - decrement_pending(); - } }); } From 1c150a460fdb67703aeba9eb515c0a30e2348ccb Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 6 May 2026 22:37:46 +0200 Subject: [PATCH 06/32] fix: abort running obsolete async branches (#18118) We shouldn't continue executing async work where we know the surrounding branch is destroyed already, it can leave to noisy "derived inter" warnings or even runtime errors ("cannot stringify symbol" when running a template effect with an uninitialized source). Neither should we warn about waterfalls on an already-destroyed async effect. Fixes #18097 (though strictly speaking that particular instance is also fixed by #18117 which fixes the underlying cause for the reruns; this one is necessary in itself though, as shown by the new test) --------- Co-authored-by: Rich Harris --- .changeset/stupid-baboons-fall.md | 5 +++ .../src/internal/client/reactivity/async.js | 8 +++-- .../internal/client/reactivity/deriveds.js | 2 +- .../Child.svelte | 6 ++++ .../_config.js | 27 ++++++++++++++++ .../main.svelte | 31 +++++++++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .changeset/stupid-baboons-fall.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte diff --git a/.changeset/stupid-baboons-fall.md b/.changeset/stupid-baboons-fall.md new file mode 100644 index 0000000000..66895ad015 --- /dev/null +++ b/.changeset/stupid-baboons-fall.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: abort running obsolete async branches diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 61fff31f8a..5e418d81a1 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -55,14 +55,16 @@ export function flatten(blockers, sync, async, fn) { /** @param {Value[]} values */ function finish(values) { + if ((parent.f & DESTROYED) !== 0) { + return; + } + restore(); try { fn(values); } catch (error) { - if ((parent.f & DESTROYED) === 0) { - invoke_error_boundary(error, parent); - } + invoke_error_boundary(error, parent); } unset_context(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index eb934d96ff..070dfc8ff3 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -239,7 +239,7 @@ export function async_derived(fn, label, location) { recent_async_deriveds.add(signal); setTimeout(() => { - if (recent_async_deriveds.has(signal)) { + if (recent_async_deriveds.has(signal) && (effect.f & DESTROYED) === 0) { w.await_waterfall(/** @type {string} */ (signal.label), location); recent_async_deriveds.delete(signal); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte new file mode 100644 index 0000000000..1d9bdfada2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js new file mode 100644 index 0000000000..83364706e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs, warnings }) { + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, [1, 2]); + + // no await waterfall / inert derived warnings + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte new file mode 100644 index 0000000000..fe01ae457e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte @@ -0,0 +1,31 @@ + + + + + + + {#if count % 2 === 0} + {@const double = count * 2} +

true

+ {await push(count)} {double} + + {:else} +

false

+ + {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
From 4d2b6c61e0259f497c14d5148fe80e16da684ef2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 6 May 2026 18:24:23 -0400 Subject: [PATCH 07/32] chore: make `batch.#pending` a number rather than a map (#18184) there's no reason for this to be a map --- .../src/internal/client/reactivity/batch.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2801445ae7..2d555bb34d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -130,10 +130,9 @@ export class Batch { #fork_commit_callbacks = new Set(); /** - * Async effects that are currently in flight - * @type {Map} + * The number of async effects that are currently in flight */ - #pending = new Map(); + #pending = 0; /** * Async effects that are currently in flight, _not_ inside a pending boundary @@ -327,7 +326,7 @@ export class Batch { reset_branch(e, t); } } else { - if (this.#pending.size === 0) { + if (this.#pending === 0) { batches.delete(this); } @@ -637,8 +636,7 @@ export class Batch { * @param {Effect} effect */ increment(blocking, effect) { - let pending_count = this.#pending.get(effect) ?? 0; - this.#pending.set(effect, pending_count + 1); + this.#pending += 1; if (blocking) { let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; @@ -652,13 +650,7 @@ export class Batch { * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ 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); - } + this.#pending -= 1; if (blocking) { let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; From 908c9d031283955629f784a9a9cd523172c10353 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 7 May 2026 04:30:24 -0400 Subject: [PATCH 08/32] fix: leave stale promises to wait for a later resolution, instead of rejecting (#18180) This incorporates some of the fixes and insights from #18177, but gets rid of the `skip` logic. Instead, we differentiate between _stale_ and _obsolete_ promises. A promise is stale if it has been overtaken by a subsequent update, and was rejected with `STALE_REACTION`: ```ts async function search(query: string) { return fetch(`/search?q=${query}`, { signal: getAbortSignal() }).then((r) => r.json()); } ``` In this case, if we start typing `pot`, and then finish typing `potato`, the first promise will eventually resolve with the results for `/search?q=potato`, instead of the batch entering a weird limbo/zombie state. A promise is obsolete if it belongs to a now-destroyed effect, meaning that toggling `show` doesn't result in an accumulation of never-resolving batches: ```svelte {#if show} {await neverResolves()} {/if} ``` Fixes part of https://github.com/sveltejs/kit/issues/15431 --------- Co-authored-by: Simon Holthausen Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/big-webs-sing.md | 5 +++ .../src/internal/client/reactivity/async.js | 17 ++++--- .../src/internal/client/reactivity/batch.js | 19 ++++++-- .../internal/client/reactivity/deriveds.js | 43 ++++++++++-------- .../samples/async-batch-order/_config.js | 30 +++++++++++++ .../samples/async-batch-order/main.svelte | 21 +++++++++ .../samples/async-stale-derived-4/_config.js | 28 ++++++++++++ .../samples/async-stale-derived-4/main.svelte | 21 +++++++++ .../samples/async-stale-derived-5/_config.js | 33 ++++++++++++++ .../samples/async-stale-derived-5/main.svelte | 44 +++++++++++++++++++ .../samples/async-stale-derived-6/_config.js | 34 ++++++++++++++ .../samples/async-stale-derived-6/main.svelte | 21 +++++++++ .../samples/async-stale-derived-7/_config.js | 34 ++++++++++++++ .../samples/async-stale-derived-7/main.svelte | 22 ++++++++++ 14 files changed, 342 insertions(+), 30 deletions(-) create mode 100644 .changeset/big-webs-sing.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte diff --git a/.changeset/big-webs-sing.md b/.changeset/big-webs-sing.md new file mode 100644 index 0000000000..946a41d881 --- /dev/null +++ b/.changeset/big-webs-sing.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: leave stale promises to wait for a later resolution, instead of rejecting diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 5e418d81a1..c1d4cbcd67 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -70,20 +70,23 @@ export function flatten(blockers, sync, async, fn) { unset_context(); } + var decrement_pending = increment_pending(); + // Fast path: blockers but no async expressions if (async.length === 0) { - /** @type {Promise} */ (blocker_promise).then(() => finish(sync.map(d))); + /** @type {Promise} */ (blocker_promise) + .then(() => finish(sync.map(d))) + .finally(decrement_pending); + return; } - var decrement_pending = increment_pending(); - // Full path: has async expressions function run() { Promise.all(async.map((expression) => async_derived(expression))) .then((result) => finish([...sync.map(d), ...result])) .catch((error) => invoke_error_boundary(error, parent)) - .finally(() => decrement_pending()); + .finally(decrement_pending); } if (blocker_promise) { @@ -325,7 +328,7 @@ export function run(thunks) { // wait one more tick, so that template effects are // guaranteed to run before `$effect(...)` .then(() => Promise.resolve()) - .finally(() => decrement_pending()); + .finally(decrement_pending); return blockers; } @@ -349,8 +352,8 @@ export function increment_pending() { boundary.update_pending_count(1, batch); batch.increment(blocking, effect); - return (skip = false) => { + return () => { boundary.update_pending_count(-1, batch); - batch.decrement(blocking, effect, skip); + batch.decrement(blocking, effect); }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2d555bb34d..a106806721 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -110,6 +110,13 @@ export class Batch { */ previous = new Map(); + /** + * Async effects which this batch doesn't take into account anymore when calculating blockers, + * as it has a value for it already. + * @type {Set} + */ + unblocked = new Set(); + /** * When the batch is committed (and the DOM is updated), we need to remove old branches * and append new ones by calling the functions added inside (if/each/key/etc) blocks @@ -200,6 +207,8 @@ export class Batch { #is_blocked() { for (const batch of this.#blockers) { for (const effect of batch.#blocking_pending.keys()) { + if (this.unblocked.has(effect)) continue; + var skipped = false; var e = effect; @@ -647,9 +656,8 @@ export class Batch { /** * @param {boolean} blocking * @param {Effect} effect - * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ - decrement(blocking, effect, skip) { + decrement(blocking, effect) { this.#pending -= 1; if (blocking) { @@ -662,12 +670,15 @@ export class Batch { } } - if (this.#decrement_queued || skip) return; + if (this.#decrement_queued) return; this.#decrement_queued = true; queue_micro_task(() => { this.#decrement_queued = false; - this.flush(); + + if (batches.has(this)) { + this.flush(); + } }); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 070dfc8ff3..794da4f2b0 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -100,6 +100,8 @@ export function derived(fn) { return signal; } +const OBSOLETE = {}; + /** * @template V * @param {() => V | Promise} fn @@ -118,7 +120,7 @@ export function async_derived(fn, label, location) { var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); - if (DEV) signal.label = label; + if (DEV) signal.label = label ?? fn.toString(); // only suspend in async deriveds created on initialisation var should_suspend = !active_reaction; @@ -141,7 +143,13 @@ export function async_derived(fn, label, location) { // If this code is changed at some point, make sure to still access the then property // of fn() to read any signals it might access, so that we track them as dependencies. // We call `unset_context` to undo any `save` calls that happen inside `fn()` - Promise.resolve(fn()).then(d.resolve, d.reject).finally(unset_context); + Promise.resolve(fn()) + .then(d.resolve, (e) => { + // if the promise was rejected by the user, via `getAbortSignal`, then + // wait for a subsequent resolution instead of flushing the batch + if (e !== STALE_REACTION) d.reject(e); + }) + .finally(unset_context); } catch (error) { d.reject(error); unset_context(); @@ -180,15 +188,13 @@ export function async_derived(fn, label, location) { } if (/** @type {Boundary} */ (parent.b).is_rendered()) { - deferreds.get(batch)?.reject(STALE_REACTION); - deferreds.delete(batch); // delete to ensure correct order in Map iteration below + deferreds.get(batch)?.reject(OBSOLETE); } else { // While the boundary is still showing pending, a new run supersedes all older in-flight runs // for this async expression. Cancel eagerly so resolution cannot commit stale values. for (const d of deferreds.values()) { - d.reject(STALE_REACTION); + d.reject(OBSOLETE); } - deferreds.clear(); } deferreds.set(batch, d); @@ -203,16 +209,10 @@ export function async_derived(fn, label, location) { reactivity_loss_tracker = null; } - if (decrement_pending) { - // don't trigger an update if we're only here because - // the promise was superseded before it could resolve - var skip = error === STALE_REACTION; - decrement_pending(skip); - } + decrement_pending?.(); + deferreds.delete(batch); - if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) { - return; - } + if (error === OBSOLETE) return; batch.activate(); @@ -230,9 +230,14 @@ export function async_derived(fn, label, location) { // All prior async derived runs are now stale for (const [b, d] of deferreds) { - deferreds.delete(b); - if (b === batch) break; - d.resolve(value); + if (b.id < batch.id) { + // Don't delete + resolve directly, instead only do that once + // the current batch commits. This way we avoid tearing when + // `b` is rendering through the early resolve while `batch` is + // still pending. + batch.unblocked.add(effect); + batch.oncommit(() => d.resolve(value)); + } } if (DEV && location !== undefined) { @@ -255,7 +260,7 @@ export function async_derived(fn, label, location) { teardown(() => { for (const d of deferreds.values()) { - d.reject(STALE_REACTION); + d.reject(OBSOLETE); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js new file mode 100644 index 0000000000..53cceb9d54 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift, middle] = target.querySelectorAll('button'); + const [div] = target.querySelectorAll('div'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded + await tick(); + assert.htmlEqual(div.innerHTML, '2 2'); + + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '3 3'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte new file mode 100644 index 0000000000..0289380d78 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte @@ -0,0 +1,21 @@ + + +
+ {a} {await delay(a)} + {#if a < 2} + {await delay(a)} + {/if} +
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js new file mode 100644 index 0000000000..e1555c0062 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, hide, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + pop.click(); + await tick(); + hide.click(); // hides the if block, which cancels the pending async inside, which means the batch can complete + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte new file mode 100644 index 0000000000..5ff3263d39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +{await push(count)} +{#if show} + {await push(count)} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js new file mode 100644 index 0000000000..05d92e9df2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1 = 1

fizz: true

buzz: true

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

1 = 1

fizz: true

buzz: true

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

3 = 3

fizz: true

buzz: false

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte new file mode 100644 index 0000000000..b2a40e65b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/main.svelte @@ -0,0 +1,44 @@ + + + + + + +

{n} = {await push(n)}

+ +{#if true} +

fizz: {fizz}

+{/if} + +{#if true} +

buzz: {buzz}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js new file mode 100644 index 0000000000..5b951d6c49 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, pop, shift] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte new file mode 100644 index 0000000000..4515e7a488 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js new file mode 100644 index 0000000000..a6f1833d67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, shift_1, pop_1, shift_2] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + // Check that the first batch can still resolve before the second even if one of its async values + // is already superseeded (but the subsequent batch as a whole is still pending). + shift_1.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte new file mode 100644 index 0000000000..fec684c257 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

From fcaa8ce7236069755912df5f7d29c7e5e5c57974 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 7 May 2026 20:34:26 +0200 Subject: [PATCH 09/32] fix: reapply new batch after `#commit` (#18186) Fixes a regression of #18170 (not released yet therefore no changeset). `current_batch` is nulled out if the `#commit` rebases other branches, and that can lead to nullpointers down the line. No test right now but it's part of getting the failing SvelteKit test passing. --- packages/svelte/src/internal/client/reactivity/batch.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a106806721..8f14cb4437 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -363,6 +363,8 @@ export class Batch { // 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(); + // Rebases can activate other batches or null it out, therefore restore the new one here + current_batch = next_batch; } // Edge case: During traversal new branches might create effects that run immediately and set state, From 9950b22869b084052cee9e708a79d7cc3bd12be8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:08:04 -0400 Subject: [PATCH 10/32] fix: process batches created in effect and immediately flushed (#18194) no changeset because the bug this fixes hasn't been released --- .../src/internal/client/reactivity/batch.js | 21 ++++++---------- .../async-flushsync-in-effect/_config.js | 25 +++++++++++++++++++ .../async-flushsync-in-effect/main.svelte | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 8f14cb4437..418e2a8f1a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -372,12 +372,11 @@ export class Batch { // once more in that case - most of the time this will just clean up dirty branches. if (this.#roots.length > 0) { const batch = (next_batch ??= this); + batches.add(batch); batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } if (next_batch !== null) { - batches.add(next_batch); - if (DEV) { for (const source of this.current.keys()) { /** @type {Set} */ (source_stacks).add(source); @@ -728,20 +727,14 @@ export class Batch { static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); + batches.add(batch); - if (!is_processing) { - batches.add(current_batch); - - if (!is_flushing_sync) { - queue_micro_task(() => { - if (batch.#started) { - // a flushSync happened in the meantime - return; - } - + if (!is_processing && !is_flushing_sync) { + queue_micro_task(() => { + if (!batch.#started) { batch.flush(); - }); - } + } + }); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js new file mode 100644 index 0000000000..59a81afd35 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 0'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 1'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte new file mode 100644 index 0000000000..12c4dd578e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte @@ -0,0 +1,25 @@ + + + + +{await push(count)} From e00944ffd1940110e4c1aeb8912fa26858e610bc Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 8 May 2026 18:08:26 +0200 Subject: [PATCH 11/32] fix: correctly compile component member expressions for SSR (#18192) Fixes #18191 --- .changeset/quiet-teams-pick.md | 5 +++++ .../phases/3-transform/server/visitors/Component.js | 6 +++++- .../samples/dynamic-component-member/Icon.svelte | 1 + .../samples/dynamic-component-member/_config.js | 8 ++++++++ .../samples/dynamic-component-member/main.svelte | 8 ++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-teams-pick.md create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md new file mode 100644 index 0000000000..ed046168be --- /dev/null +++ b/.changeset/quiet-teams-pick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly compile component member expressions for SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js index 8e7d7bcdbf..ed202edd3b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js @@ -9,5 +9,9 @@ import { build_inline_component } from './shared/component.js'; * @param {ComponentContext} context */ export function Component(node, context) { - build_inline_component(node, /** @type {Expression} */ (context.visit(b.id(node.name))), context); + build_inline_component( + node, + /** @type {Expression} */ (context.visit(b.member_id(node.name))), + context + ); } diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte new file mode 100644 index 0000000000..77cdcabaf7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte @@ -0,0 +1 @@ +x diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js new file mode 100644 index 0000000000..22412de14d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client', 'server'], + + html: `x`, + ssrHtml: `x` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte new file mode 100644 index 0000000000..b0f95b2a31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte @@ -0,0 +1,8 @@ + + + From 9ffc13d7c3a9360591e1ad45209b10024e3a8333 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 12:55:29 -0400 Subject: [PATCH 12/32] fix: remove `source.updated` stack traces after `flush` (#18196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noticed that we're not actually doing anything with `source_stacks` — we shadow the module-level declaration in `flush`, which means we just keep appending to it and then clearing a different (and empty) set. As a result, any source that ever gets an `updated` property never gets rid of it. This probably causes a memory leak? Anyway, this fixes it. ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [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/red-crabs-ring.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 .changeset/red-crabs-ring.md diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md new file mode 100644 index 0000000000..82b53c5464 --- /dev/null +++ b/.changeset/red-crabs-ring.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reset `source.updated` stack traces after `flush` diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 418e2a8f1a..dfa53788f3 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -85,7 +85,9 @@ export let collected_effects = null; export let legacy_updates = null; var flush_count = 0; -var source_stacks = DEV ? new Set() : null; + +/** @type {Set} */ +var source_stacks = new Set(); let uid = 1; @@ -273,6 +275,14 @@ export class Batch { infinite_loop_guard(); } + if (DEV) { + // track all the values that were updated during this flush, + // so that they can be reset afterwards + for (const value of this.current.keys()) { + source_stacks.add(value); + } + } + // we only reschedule previously-deferred effects if we expect // to be able to run them after processing the batch if (!this.#is_deferred()) { @@ -377,12 +387,6 @@ export class Batch { } if (next_batch !== null) { - if (DEV) { - for (const source of this.current.keys()) { - /** @type {Set} */ (source_stacks).add(source); - } - } - next_batch.#process(); } } @@ -481,9 +485,11 @@ export class Batch { } flush() { - var source_stacks = DEV ? new Set() : null; - try { + if (DEV) { + source_stacks.clear(); + } + is_processing = true; current_batch = this; @@ -501,7 +507,7 @@ export class Batch { old_values.clear(); if (DEV) { - for (const source of /** @type {Set} */ (source_stacks)) { + for (const source of source_stacks) { source.updated = null; } } From af5b9724ab31e8570d9c69ac24c009bb0913960a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 8 May 2026 13:41:08 -0400 Subject: [PATCH 13/32] chore: flatten `#process()` (#18190) Realised that we can make the logic in `#process()` a little easier to follow by early-returning if a batch is deferred (or blocked) after processing, since nothing that happens after the `if` block applies in that case --- .../src/internal/client/reactivity/batch.js | 36 +++++++------ .../_config.js | 54 +++++++++++++++++++ .../main.svelte | 29 ++++++++++ 3 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index dfa53788f3..d1f3ce08af 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -344,26 +344,28 @@ export class Batch { for (const [e, t] of this.#skipped_branches) { reset_branch(e, t); } - } else { - if (this.#pending === 0) { - batches.delete(this); + + if (updates.length > 0) { + /** @type {Batch} */ (/** @type {unknown} */ (current_batch)).#process(); } - // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + return; + } - // append/remove branches - for (const fn of this.#commit_callbacks) fn(this); - this.#commit_callbacks.clear(); + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); - previous_batch = this; - flush_queued_effects(render_effects); - flush_queued_effects(effects); - previous_batch = null; + // append/remove branches + for (const fn of this.#commit_callbacks) fn(this); + this.#commit_callbacks.clear(); - this.#deferred?.resolve(); - } + previous_batch = this; + flush_queued_effects(render_effects); + flush_queued_effects(effects); + previous_batch = null; + + this.#deferred?.resolve(); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); @@ -371,7 +373,7 @@ export class Batch { // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // 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)) { + if (async_mode_flag && this.#pending === 0) { this.#commit(); // Rebases can activate other batches or null it out, therefore restore the new one here current_batch = next_batch; @@ -530,6 +532,8 @@ export class Batch { } #commit() { + batches.delete(this); + // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js new file mode 100644 index 0000000000..d7293f9b70 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null +export default test({ + skip_no_async: true, + + async test({ target, assert }) { + assert.htmlEqual( + target.innerHTML, + `

0

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1

hello 1 ` + ); + + const input = target.querySelector('input'); + ok(input); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

hello 2 ` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

3

hello 3 ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte new file mode 100644 index 0000000000..4abfd3c2f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte @@ -0,0 +1,29 @@ + + + +

{count}

+ +{#if watcherA} + + {#if true} + {await 'hello'} + {/if} + + {$watcherA} + +{:else} + +{/if} From 1688c84be9102dc08318c74e4c80bd0b62abcfdd Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 13 May 2026 00:25:11 +0200 Subject: [PATCH 14/32] chore: allow `null` for `pending` in typings (#18201) Closes #18166 We handle all falsy values at runtime already and for basically every other dom attribute also allow `null`, so no reason not to do this. --- .changeset/fine-bushes-marry.md | 5 +++++ packages/svelte/elements.d.ts | 6 +++--- .../svelte/src/internal/client/dom/blocks/boundary.js | 9 ++++----- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 .changeset/fine-bushes-marry.md diff --git a/.changeset/fine-bushes-marry.md b/.changeset/fine-bushes-marry.md new file mode 100644 index 0000000000..ccba53babe --- /dev/null +++ b/.changeset/fine-bushes-marry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: allow `null` for `pending` in typings diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index f18b7dea98..daa40635b6 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -2067,9 +2067,9 @@ export interface SvelteHTMLElements { }; 'svelte:head': { [name: string]: any }; 'svelte:boundary': { - onerror?: (error: unknown, reset: () => void) => void; - failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>; - pending?: import('svelte').Snippet; + onerror?: ((error: unknown, reset: () => void) => void) | null | undefined; + failed?: import('svelte').Snippet<[error: unknown, reset: () => void]> | null | undefined; + pending?: import('svelte').Snippet | null | undefined; }; [name: string]: { [name: string]: any }; diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 028b82ab92..beaa7d6869 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -35,19 +35,18 @@ import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; -import { Batch, current_batch, previous_batch, schedule_effect } from '../../reactivity/batch.js'; +import { Batch, current_batch } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; import { create_text } from '../operations.js'; import { defer_effect } from '../../reactivity/utils.js'; -import { set_signal_status } from '../../reactivity/status.js'; /** * @typedef {{ - * onerror?: (error: unknown, reset: () => void) => void; - * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; - * pending?: (anchor: Node) => void; + * onerror?: ((error: unknown, reset: () => void) => void) | null; + * failed?: ((anchor: Node, error: () => unknown, reset: () => () => void) => void) | null; + * pending?: ((anchor: Node) => void) | null; * }} BoundaryProps */ From 5122936edb3c14e9a602e579727479b49cbd3239 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 13 May 2026 16:06:56 -0400 Subject: [PATCH 15/32] fix: treat batches as a linked list (#18205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is another attempt to address some of the tricky edge cases that arise when async batches resolve out of order. The idea is this: Essentially, when a batch resolves: 1. we find the latest batch it shares changes with 2. if none exists (either because this is the earliest batch, or because it is independent of any earlier batches): - we commit it: effects are flushed, `oncommit` callbacks are run - we restart any async work in later batches that depends on both the committed values and the later batch's changes 3. otherwise, we don't commit the batch. instead: - we merge the changes from the later batch onto the earlier batch. in some cases this may mean restarting async work on the earlier batch, but we avoid doing so unnecessarily. - we then `#process()` the earlier batch. if it resolves, goto 1 This feels like it ought to work. There are still two failing tests, which I'm currently looking into. Notable changes: - Instead of having a `batches` set, we have a linked list. When a batch resolves, this makes it easy to find the batch that it should be merged into - The `#is_deferred` logic now takes account of skipped effects — there's no need to wait for a promise inside a falsy `if` block (this accounts for the change in the `async-inner-after-outer` test) - We no longer care about `#blockers` I would like to believe that this approach will allow us to simplify and delete some code, for example the `rebase` logic, though that remains to be seen. It also feels like some version of #18035 would be helpful. - closes #18189 - closes #18162 - fixes https://github.com/sveltejs/kit/issues/15431 ### 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: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen --- .changeset/shiny-squids-whisper.md | 5 + .../internal/client/dom/blocks/svelte-head.js | 11 +- .../src/internal/client/reactivity/batch.js | 250 +++++++++++++----- .../internal/client/reactivity/deriveds.js | 27 +- .../async-inner-after-outer/_config.js | 12 - .../_config.js | 20 ++ .../main.svelte | 26 ++ .../samples/async-stale-derived-8/_config.js | 20 ++ .../samples/async-stale-derived-8/main.svelte | 23 ++ 9 files changed, 298 insertions(+), 96 deletions(-) create mode 100644 .changeset/shiny-squids-whisper.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte diff --git a/.changeset/shiny-squids-whisper.md b/.changeset/shiny-squids-whisper.md new file mode 100644 index 0000000000..a8d2d7378c --- /dev/null +++ b/.changeset/shiny-squids-whisper.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: replacing async 'blocking' strategy with 'merging' diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 7cab6c3385..5721f7b056 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -1,8 +1,8 @@ /** @import { TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; -import { block } from '../../reactivity/effects.js'; -import { COMMENT_NODE, EFFECT_PRESERVED, HEAD_EFFECT } from '#client/constants'; +import { block, branch } from '../../reactivity/effects.js'; +import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; /** * @param {string} hash @@ -49,9 +49,10 @@ export function head(hash, render_fn) { } try { - // normally a branch is the child of a block and would have the EFFECT_PRESERVED flag, - // but since head blocks don't necessarily only have direct branch children we add it on the block itself - block(() => render_fn(anchor), HEAD_EFFECT | EFFECT_PRESERVED); + block(() => { + var e = branch(() => render_fn(anchor)); + e.f |= HEAD_EFFECT; + }); } finally { if (was_hydrating) { set_hydrating(true); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index d1f3ce08af..d822834324 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -41,8 +41,11 @@ import { legacy_is_updating_store } from './store.js'; import { invariant } from '../../shared/dev.js'; import { log_effect_tree } from '../dev/debug.js'; -/** @type {Set} */ -const batches = new Set(); +/** @type {Batch | null} */ +let first_batch = null; + +/** @type {Batch | null} */ +let last_batch = null; /** @type {Batch | null} */ export let current_batch = null; @@ -94,9 +97,20 @@ let uid = 1; export class Batch { id = uid++; - /** True as soon as `#process()` was called */ + /** True as soon as `#process` was called */ #started = false; + linked = true; + + /** @type {Batch | null} */ + #prev = null; + + /** @type {Batch | null} */ + #next = null; + + /** @type {Map>>} */ + async_deriveds = new Map(); + /** * 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) @@ -199,33 +213,24 @@ export class Batch { #decrement_queued = false; - /** @type {Set} */ - #blockers = new Set(); - #is_deferred() { - 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()) { - if (this.unblocked.has(effect)) continue; + if (this.is_fork) return true; - var skipped = false; - var e = effect; + for (const effect of this.#blocking_pending.keys()) { + var e = effect; + var skipped = false; - while (e.parent !== null) { - if (this.#skipped_branches.has(e)) { - skipped = true; - break; - } - - e = e.parent; + while (e.parent !== null) { + if (this.#skipped_branches.has(e)) { + skipped = true; + break; } - if (!skipped) { - return true; - } + e = e.parent; + } + + if (!skipped) { + return true; } } @@ -271,7 +276,7 @@ export class Batch { this.#started = true; if (flush_count++ > 1000) { - batches.delete(this); + this.#unlink(); infinite_loop_guard(); } @@ -337,7 +342,8 @@ export class Batch { collected_effects = null; legacy_updates = null; - if (this.#is_deferred() || this.#is_blocked()) { + // if the batch has outstanding pending work, stash effects and bail + if (this.#is_deferred()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -352,6 +358,13 @@ export class Batch { return; } + const earlier_batch = this.#find_earlier_batch(); + + if (earlier_batch) { + earlier_batch.#merge(this); + return; + } + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); @@ -369,11 +382,15 @@ export class Batch { var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + if (this.linked && this.#pending === 0) { + this.#unlink(); + } + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // 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 && this.#pending === 0) { + if (async_mode_flag && !this.linked) { this.#commit(); // Rebases can activate other batches or null it out, therefore restore the new one here current_batch = next_batch; @@ -383,8 +400,12 @@ export class Batch { // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // once more in that case - most of the time this will just clean up dirty branches. if (this.#roots.length > 0) { - const batch = (next_batch ??= this); - batches.add(batch); + if (next_batch === null) { + next_batch = this; + this.#link(); + } + + const batch = next_batch; batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } @@ -445,6 +466,82 @@ export class Batch { } } + #find_earlier_batch() { + var batch = this.#prev; + + while (batch !== null) { + if (!batch.is_fork) { + // if the batches are connected, break + for (const [value, [, is_derived]] of this.current) { + if (batch.current.has(value) && !is_derived) { + return batch; + } + } + } + + batch = batch.#prev; + } + + return null; + } + + /** + * @param {Batch} batch + */ + #merge(batch) { + for (const [source, value] of batch.current) { + if (!this.previous.has(source) && batch.previous.has(source)) { + this.previous.set(source, batch.previous.get(source)); + } + + this.current.set(source, value); + } + + for (const [effect, deferred] of batch.async_deriveds) { + const d = this.async_deriveds.get(effect); + if (d) deferred.promise.then(d.resolve); + } + + /** + * mark all effects that depend on `batch.current`, except the + * async effects that we just resolved (TODO unless they depend + * on values in this batch that are NOT in the later batch?). + * Through this we also will populate the correct #skipped_branches, + * oncommit callbacks etc, so we don't need to merge them separately. + * @param {Value} value + */ + const mark = (value) => { + var reactions = value.reactions; + if (reactions === null) return; + + for (const reaction of reactions) { + var flags = reaction.f; + + if ((flags & DERIVED) !== 0) { + mark(/** @type {Derived} */ (reaction)); + } else { + var effect = /** @type {Effect} */ (reaction); + + if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) { + this.#maybe_dirty_effects.delete(effect); + set_signal_status(effect, DIRTY); + this.schedule(effect); + } + } + } + }; + + for (const source of this.current.keys()) { + mark(source); + } + + this.oncommit(() => batch.discard()); + batch.#unlink(); + + current_batch = this; + this.#process(); + } + /** * @param {Effect[]} effects */ @@ -521,7 +618,7 @@ export class Batch { this.#discard_callbacks.clear(); this.#fork_commit_callbacks.clear(); - batches.delete(this); + this.#unlink(); } /** @@ -532,13 +629,13 @@ export class Batch { } #commit() { - batches.delete(this); + this.#unlink(); // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more // recent value for a given source - for (const batch of batches) { + for (let batch = first_batch; batch !== null; batch = batch.#next) { var is_earlier = batch.id < this.id; /** @type {Source[]} */ @@ -561,6 +658,15 @@ export class Batch { sources.push(source); } + if (is_earlier) { + // TODO do we need to restart these in some cases, instead of + // immediately resolving them? Likely not because of how this.apply() works. + for (const [effect, deferred] of this.async_deriveds) { + const d = batch.async_deriveds.get(effect); + if (d) deferred.promise.then(d.resolve); + } + } + if (!batch.#started) continue; // Re-run async/block effects that depend on distinct values changed in both batches @@ -638,17 +744,6 @@ 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(); - } - } - } } /** @@ -687,7 +782,7 @@ export class Batch { queue_micro_task(() => { this.#decrement_queued = false; - if (batches.has(this)) { + if (this.linked) { this.flush(); } }); @@ -737,7 +832,7 @@ export class Batch { static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); - batches.add(batch); + batch.#link(); if (!is_processing && !is_flushing_sync) { queue_micro_task(() => { @@ -752,7 +847,7 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && batches.size === 1)) { + if (!async_mode_flag || (!this.is_fork && this.#prev === null && this.#next === null)) { batch_values = null; return; } @@ -764,28 +859,33 @@ export class Batch { batch_values.set(source, value); } - // ...and undo changes belonging to other batches unless they block this one - for (const batch of batches) { + // ...and undo changes belonging to other batches unless they intersect + for (let batch = first_batch; batch !== null; batch = batch.#next) { if (batch === this || batch.is_fork) continue; - // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset + // If two batches intersect, the latter batch will be merged into the earlier batch, + // and we should treat them as a single set of changes 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 + // Derived values don't partake in the intersection 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 (this.current.has(source)) { + intersects = true; + break; + } } } - if (intersects && differs) { - this.#blockers.add(batch); - } else { + // Since the latter batch merges into the earlier (if it resolves before the earlier one), + // we treat the earlier values as "already applied". This way we don't need to rerun async + // effects of the earlier batch in case they are merged. + // As a result you can think of batch_values as having the latest values of all intersecting + // batches up until this batch. + if (!intersects) { for (const [source, previous] of batch.previous) { if (!batch_values.has(source)) { batch_values.set(source, previous); @@ -851,6 +951,36 @@ export class Batch { this.#roots.push(e); } + + #link() { + if (last_batch === null) { + first_batch = last_batch = this; + } else { + last_batch.#next = this; + this.#prev = last_batch; + } + + last_batch = this; + } + + #unlink() { + var prev = this.#prev; + var next = this.#next; + + if (prev === null) { + first_batch = next; + } else { + prev.#next = next; + } + + if (next === null) { + last_batch = prev; + } else { + next.#prev = prev; + } + + this.linked = false; + } } // TODO Svelte@6 think about removing the callback argument. @@ -1234,7 +1364,7 @@ export function fork(fn) { return; } - if (!batches.has(batch)) { + if (!batch.linked) { e.fork_discarded(); } @@ -1280,7 +1410,7 @@ export function fork(fn) { source.wv = increment_write_version(); } - if (!committed && batches.has(batch)) { + if (!committed && batch.linked) { batch.discard(); } } @@ -1291,5 +1421,5 @@ export function fork(fn) { * Forcibly remove all current batches, to prevent cross-talk between tests */ export function clear() { - batches.clear(); + first_batch = last_batch = null; } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 794da4f2b0..83e23dfab6 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -100,7 +100,7 @@ export function derived(fn) { return signal; } -const OBSOLETE = {}; +export const OBSOLETE = Symbol('obsolete'); /** * @template V @@ -125,8 +125,8 @@ export function async_derived(fn, label, location) { // only suspend in async deriveds created on initialisation var should_suspend = !active_reaction; - /** @type {Map>>} */ - var deferreds = new Map(); + /** @type {Set>>} */ + var deferreds = new Set(); async_effect(() => { var effect = /** @type {Effect} */ (active_effect); @@ -188,7 +188,7 @@ export function async_derived(fn, label, location) { } if (/** @type {Boundary} */ (parent.b).is_rendered()) { - deferreds.get(batch)?.reject(OBSOLETE); + batch.async_deriveds.get(effect)?.reject(OBSOLETE); } else { // While the boundary is still showing pending, a new run supersedes all older in-flight runs // for this async expression. Cancel eagerly so resolution cannot commit stale values. @@ -197,7 +197,8 @@ export function async_derived(fn, label, location) { } } - deferreds.set(batch, d); + deferreds.add(d); + batch.async_deriveds.set(effect, d); } /** @@ -210,7 +211,7 @@ export function async_derived(fn, label, location) { } decrement_pending?.(); - deferreds.delete(batch); + deferreds.delete(d); if (error === OBSOLETE) return; @@ -228,18 +229,6 @@ export function async_derived(fn, label, location) { internal_set(signal, value); - // All prior async derived runs are now stale - for (const [b, d] of deferreds) { - if (b.id < batch.id) { - // Don't delete + resolve directly, instead only do that once - // the current batch commits. This way we avoid tearing when - // `b` is rendering through the early resolve while `batch` is - // still pending. - batch.unblocked.add(effect); - batch.oncommit(() => d.resolve(value)); - } - } - if (DEV && location !== undefined) { recent_async_deriveds.add(signal); @@ -259,7 +248,7 @@ export function async_derived(fn, label, location) { }); teardown(() => { - for (const d of deferreds.values()) { + for (const d of deferreds) { d.reject(OBSOLETE); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js index 8905ee4bf5..c12eba7d17 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js @@ -34,18 +34,6 @@ export default test({ shift?.click(); await tick(); - assert.htmlEqual( - target.innerHTML, - ` -

true

- - - ` - ); - - shift?.click(); - await tick(); - assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js new file mode 100644 index 0000000000..c8bc4c986f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte new file mode 100644 index 0000000000..7689af049c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte @@ -0,0 +1,26 @@ + + + + + +{#if count > 0} + + {await push(count)} {count} {other} + {#snippet failed()}boom{/snippet} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js new file mode 100644 index 0000000000..fd0fc0de48 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); // showing nothing here yet would also be ok + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte new file mode 100644 index 0000000000..42b7206b56 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{#if count > 0} + {await push(count)} {count} {other} +{/if} From ef4b97dfabfd7a23b27933e18f7393587c343d66 Mon Sep 17 00:00:00 2001 From: quyentonndbs Date: Thu, 14 May 2026 07:31:42 -0500 Subject: [PATCH 16/32] fix: duplicated "of" in events.js comment (#18217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-line typo fix in `packages/svelte/src/internal/client/dom/elements/events.js`: "removal or moving of of the DOM" → "removal or moving of the DOM". No code/behavior change. --------- Co-authored-by: Kai Tanaka <275430420+quyentonndbs@users.noreply.github.com> Co-authored-by: Rich Harris --- packages/svelte/src/internal/client/dom/elements/events.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index e598a78949..5aa41e1c4d 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -237,9 +237,9 @@ export function handle_event_propagation(event) { }); // This started because of Chromium issue https://chromestatus.com/feature/5128696823545856, - // where removal or moving of of the DOM can cause sync `blur` events to fire, which can cause logic + // where removal or moving of the DOM can cause sync `blur` events to fire, which can cause logic // to run inside the current `active_reaction`, which isn't what we want at all. However, on reflection, - // it's probably best that all event handled by Svelte have this behaviour, as we don't really want + // it's probably best that all events handled by Svelte have this behaviour, as we don't really want // an event handler to run in the context of another reaction or effect. var previous_reaction = active_reaction; var previous_effect = active_effect; From a10e8e47a5946623a60a1e36b9023c23926eae87 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 14 May 2026 13:31:31 -0400 Subject: [PATCH 17/32] fix: keep dependencies of `$state.eager`/`pending` (alternative approach) (#18218) #18108, with two differences: - we use a global map - we use the parent reaction as the key, rather than traversing upwards for a branch I think this has the same outcome? ### 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: Simon Holthausen Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/breezy-laws-train.md | 5 +++ .../src/internal/client/reactivity/batch.js | 22 +++++++++++-- .../samples/async-eager-block/_config.js | 23 +++++++++++++ .../samples/async-eager-block/main.svelte | 20 ++++++++++++ .../samples/async-eager-each-block/_config.js | 32 +++++++++++++++++++ .../async-eager-each-block/main.svelte | 24 ++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 .changeset/breezy-laws-train.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte diff --git a/.changeset/breezy-laws-train.md b/.changeset/breezy-laws-train.md new file mode 100644 index 0000000000..5e88998a0a --- /dev/null +++ b/.changeset/breezy-laws-train.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep dependencies of `$state.eager/pending` diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index d822834324..a5c9a51eec 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -16,7 +16,8 @@ import { EAGER_EFFECT, ERROR_VALUE, MANAGED_EFFECT, - REACTION_RAN + REACTION_RAN, + DESTROYING } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property, includes } from '../../shared/utils.js'; @@ -33,7 +34,7 @@ import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js'; -import { eager_effect, unlink_effect } from './effects.js'; +import { eager_effect, teardown, unlink_effect } from './effects.js'; import { defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; import { set_signal_status } from './status.js'; @@ -1234,6 +1235,9 @@ function eager_flush() { }); } +/** @type {Map>} */ +var version_map = new Map(); + /** * Implementation of `$state.eager(fn())` * @template T @@ -1241,10 +1245,22 @@ function eager_flush() { * @returns {T} */ export function eager(fn) { - var version = source(0); var initial = true; var value = /** @type {T} */ (undefined); + if (active_reaction === null) { + return fn(); + } + + let parent = active_reaction; + + let version = version_map.get(parent) ?? source(0); + version_map.set(parent, version); + + teardown(() => { + if (parent.f & DESTROYING) version_map.delete(parent); + }); + get(version); eager_effect(() => { diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js new file mode 100644 index 0000000000..b6d283c8a5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0

loading...

' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte new file mode 100644 index 0000000000..61f4705d06 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte @@ -0,0 +1,20 @@ + + + + + +{await delay(count)} +{#if $state.eager(count) !== count} +

loading...

+{:else} +

{count}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js new file mode 100644 index 0000000000..a4921a5115 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + logs.length = 0; + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` +
  • 0 / 0
  • 0 / loading...
  • 0 / 0
` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` +
  • 0 / 0
  • 1 / 1
  • 0 / 0
` + ); + + assert.equal( + logs.some((l) => l.toString().includes('0 ') || l.toString().includes('2')), + false, + 'only the second $state.eager should have been evaluated' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte new file mode 100644 index 0000000000..f27e256e9f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte @@ -0,0 +1,24 @@ + + + + + +
    + {#each counts as count, i} +
  • + {await delay(count)} / + {#if console.log(i) || $state.eager(count) !== count} + loading... + {:else} + {count} + {/if} +
  • + {/each} +
From 55f9c85c09d625c3dd80c71ce7542f57386fafb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 14:03:20 -0400 Subject: [PATCH 18/32] Version Packages (#18158) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.55.6 ### Patch Changes - fix: leave stale promises to wait for a later resolution, instead of rejecting ([#18180](https://github.com/sveltejs/svelte/pull/18180)) - fix: keep dependencies of `$state.eager/pending` ([#18218](https://github.com/sveltejs/svelte/pull/18218)) - fix: reapply context after transforming error during SSR ([#18099](https://github.com/sveltejs/svelte/pull/18099)) - fix: don't rebase just-created batches ([#18117](https://github.com/sveltejs/svelte/pull/18117)) - chore: allow `null` for `pending` in typings ([#18201](https://github.com/sveltejs/svelte/pull/18201)) - fix: flush eager effects in production ([#18107](https://github.com/sveltejs/svelte/pull/18107)) - fix: rethrow error of failed iterable after calling `return()` ([#18169](https://github.com/sveltejs/svelte/pull/18169)) - fix: account for proxified instance when updating `bind:this` ([#18147](https://github.com/sveltejs/svelte/pull/18147)) - fix: ensure scheduled batch is flushed if not obsolete ([#18131](https://github.com/sveltejs/svelte/pull/18131)) - fix: resolve stale deriveds with latest value ([#18167](https://github.com/sveltejs/svelte/pull/18167)) - chore: remove unnecessary `increment_pending` calls ([#18183](https://github.com/sveltejs/svelte/pull/18183)) - fix: correctly compile component member expressions for SSR ([#18192](https://github.com/sveltejs/svelte/pull/18192)) - fix: reset `source.updated` stack traces after `flush` ([#18196](https://github.com/sveltejs/svelte/pull/18196)) - fix: replacing async 'blocking' strategy with 'merging' ([#18205](https://github.com/sveltejs/svelte/pull/18205)) - fix: allow `@debug` tags to reference awaited variables ([#18138](https://github.com/sveltejs/svelte/pull/18138)) - fix: re-run fallback props if dependencies update ([#18146](https://github.com/sveltejs/svelte/pull/18146)) - fix: abort running obsolete async branches ([#18118](https://github.com/sveltejs/svelte/pull/18118)) - fix: ignore comments when reading CSS values ([#18153](https://github.com/sveltejs/svelte/pull/18153)) - fix: wrap `Promise.all` in `save` during SSR ([#18178](https://github.com/sveltejs/svelte/pull/18178)) - fix: ignore false-positive errors of `$inspect` dependencies ([#18106](https://github.com/sveltejs/svelte/pull/18106)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/big-webs-sing.md | 5 ---- .changeset/breezy-laws-train.md | 5 ---- .changeset/cruel-boxes-serve.md | 5 ---- .changeset/easy-singers-retire.md | 5 ---- .changeset/fine-bushes-marry.md | 5 ---- .changeset/flat-shrimps-worry.md | 5 ---- .changeset/fresh-stars-grin.md | 5 ---- .changeset/full-waves-tease.md | 5 ---- .changeset/many-pandas-add.md | 5 ---- .changeset/modern-tables-fetch.md | 5 ---- .changeset/public-mammals-float.md | 5 ---- .changeset/quiet-teams-pick.md | 5 ---- .changeset/red-crabs-ring.md | 5 ---- .changeset/shiny-squids-whisper.md | 5 ---- .changeset/small-tools-walk.md | 5 ---- .changeset/smooth-poems-tap.md | 5 ---- .changeset/stupid-baboons-fall.md | 5 ---- .changeset/three-pears-build.md | 5 ---- .changeset/tough-knives-smell.md | 5 ---- .changeset/twelve-cooks-speak.md | 5 ---- packages/svelte/CHANGELOG.md | 44 ++++++++++++++++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 23 files changed, 46 insertions(+), 102 deletions(-) delete mode 100644 .changeset/big-webs-sing.md delete mode 100644 .changeset/breezy-laws-train.md delete mode 100644 .changeset/cruel-boxes-serve.md delete mode 100644 .changeset/easy-singers-retire.md delete mode 100644 .changeset/fine-bushes-marry.md delete mode 100644 .changeset/flat-shrimps-worry.md delete mode 100644 .changeset/fresh-stars-grin.md delete mode 100644 .changeset/full-waves-tease.md delete mode 100644 .changeset/many-pandas-add.md delete mode 100644 .changeset/modern-tables-fetch.md delete mode 100644 .changeset/public-mammals-float.md delete mode 100644 .changeset/quiet-teams-pick.md delete mode 100644 .changeset/red-crabs-ring.md delete mode 100644 .changeset/shiny-squids-whisper.md delete mode 100644 .changeset/small-tools-walk.md delete mode 100644 .changeset/smooth-poems-tap.md delete mode 100644 .changeset/stupid-baboons-fall.md delete mode 100644 .changeset/three-pears-build.md delete mode 100644 .changeset/tough-knives-smell.md delete mode 100644 .changeset/twelve-cooks-speak.md diff --git a/.changeset/big-webs-sing.md b/.changeset/big-webs-sing.md deleted file mode 100644 index 946a41d881..0000000000 --- a/.changeset/big-webs-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: leave stale promises to wait for a later resolution, instead of rejecting diff --git a/.changeset/breezy-laws-train.md b/.changeset/breezy-laws-train.md deleted file mode 100644 index 5e88998a0a..0000000000 --- a/.changeset/breezy-laws-train.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: keep dependencies of `$state.eager/pending` diff --git a/.changeset/cruel-boxes-serve.md b/.changeset/cruel-boxes-serve.md deleted file mode 100644 index 592cec4d01..0000000000 --- a/.changeset/cruel-boxes-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reapply context after transforming error during SSR diff --git a/.changeset/easy-singers-retire.md b/.changeset/easy-singers-retire.md deleted file mode 100644 index 4420286e13..0000000000 --- a/.changeset/easy-singers-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't rebase just-created batches diff --git a/.changeset/fine-bushes-marry.md b/.changeset/fine-bushes-marry.md deleted file mode 100644 index ccba53babe..0000000000 --- a/.changeset/fine-bushes-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: allow `null` for `pending` in typings diff --git a/.changeset/flat-shrimps-worry.md b/.changeset/flat-shrimps-worry.md deleted file mode 100644 index a5f76a0f9d..0000000000 --- a/.changeset/flat-shrimps-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: flush eager effects in production diff --git a/.changeset/fresh-stars-grin.md b/.changeset/fresh-stars-grin.md deleted file mode 100644 index 3d56792d1e..0000000000 --- a/.changeset/fresh-stars-grin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: rethrow error of failed iterable after calling `return()` diff --git a/.changeset/full-waves-tease.md b/.changeset/full-waves-tease.md deleted file mode 100644 index 3915334bf7..0000000000 --- a/.changeset/full-waves-tease.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: account for proxified instance when updating `bind:this` diff --git a/.changeset/many-pandas-add.md b/.changeset/many-pandas-add.md deleted file mode 100644 index 85de7acb35..0000000000 --- a/.changeset/many-pandas-add.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure scheduled batch is flushed if not obsolete diff --git a/.changeset/modern-tables-fetch.md b/.changeset/modern-tables-fetch.md deleted file mode 100644 index 89543910fa..0000000000 --- a/.changeset/modern-tables-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: resolve stale deriveds with latest value diff --git a/.changeset/public-mammals-float.md b/.changeset/public-mammals-float.md deleted file mode 100644 index d890c9e070..0000000000 --- a/.changeset/public-mammals-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: remove unnecessary `increment_pending` calls diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md deleted file mode 100644 index ed046168be..0000000000 --- a/.changeset/quiet-teams-pick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: correctly compile component member expressions for SSR diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md deleted file mode 100644 index 82b53c5464..0000000000 --- a/.changeset/red-crabs-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reset `source.updated` stack traces after `flush` diff --git a/.changeset/shiny-squids-whisper.md b/.changeset/shiny-squids-whisper.md deleted file mode 100644 index a8d2d7378c..0000000000 --- a/.changeset/shiny-squids-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: replacing async 'blocking' strategy with 'merging' diff --git a/.changeset/small-tools-walk.md b/.changeset/small-tools-walk.md deleted file mode 100644 index 2b275368b3..0000000000 --- a/.changeset/small-tools-walk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: allow `@debug` tags to reference awaited variables diff --git a/.changeset/smooth-poems-tap.md b/.changeset/smooth-poems-tap.md deleted file mode 100644 index ac160656cf..0000000000 --- a/.changeset/smooth-poems-tap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: re-run fallback props if dependencies update diff --git a/.changeset/stupid-baboons-fall.md b/.changeset/stupid-baboons-fall.md deleted file mode 100644 index 66895ad015..0000000000 --- a/.changeset/stupid-baboons-fall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: abort running obsolete async branches diff --git a/.changeset/three-pears-build.md b/.changeset/three-pears-build.md deleted file mode 100644 index 8a638149a3..0000000000 --- a/.changeset/three-pears-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ignore comments when reading CSS values diff --git a/.changeset/tough-knives-smell.md b/.changeset/tough-knives-smell.md deleted file mode 100644 index 7687188c1a..0000000000 --- a/.changeset/tough-knives-smell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: wrap `Promise.all` in `save` during SSR diff --git a/.changeset/twelve-cooks-speak.md b/.changeset/twelve-cooks-speak.md deleted file mode 100644 index d4fcd5c339..0000000000 --- a/.changeset/twelve-cooks-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ignore false-positive errors of `$inspect` dependencies diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 8e0f8a0916..e5ba5737c4 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,49 @@ # svelte +## 5.55.6 + +### Patch Changes + +- fix: leave stale promises to wait for a later resolution, instead of rejecting ([#18180](https://github.com/sveltejs/svelte/pull/18180)) + +- fix: keep dependencies of `$state.eager/pending` ([#18218](https://github.com/sveltejs/svelte/pull/18218)) + +- fix: reapply context after transforming error during SSR ([#18099](https://github.com/sveltejs/svelte/pull/18099)) + +- fix: don't rebase just-created batches ([#18117](https://github.com/sveltejs/svelte/pull/18117)) + +- chore: allow `null` for `pending` in typings ([#18201](https://github.com/sveltejs/svelte/pull/18201)) + +- fix: flush eager effects in production ([#18107](https://github.com/sveltejs/svelte/pull/18107)) + +- fix: rethrow error of failed iterable after calling `return()` ([#18169](https://github.com/sveltejs/svelte/pull/18169)) + +- fix: account for proxified instance when updating `bind:this` ([#18147](https://github.com/sveltejs/svelte/pull/18147)) + +- fix: ensure scheduled batch is flushed if not obsolete ([#18131](https://github.com/sveltejs/svelte/pull/18131)) + +- fix: resolve stale deriveds with latest value ([#18167](https://github.com/sveltejs/svelte/pull/18167)) + +- chore: remove unnecessary `increment_pending` calls ([#18183](https://github.com/sveltejs/svelte/pull/18183)) + +- fix: correctly compile component member expressions for SSR ([#18192](https://github.com/sveltejs/svelte/pull/18192)) + +- fix: reset `source.updated` stack traces after `flush` ([#18196](https://github.com/sveltejs/svelte/pull/18196)) + +- fix: replacing async 'blocking' strategy with 'merging' ([#18205](https://github.com/sveltejs/svelte/pull/18205)) + +- fix: allow `@debug` tags to reference awaited variables ([#18138](https://github.com/sveltejs/svelte/pull/18138)) + +- fix: re-run fallback props if dependencies update ([#18146](https://github.com/sveltejs/svelte/pull/18146)) + +- fix: abort running obsolete async branches ([#18118](https://github.com/sveltejs/svelte/pull/18118)) + +- fix: ignore comments when reading CSS values ([#18153](https://github.com/sveltejs/svelte/pull/18153)) + +- fix: wrap `Promise.all` in `save` during SSR ([#18178](https://github.com/sveltejs/svelte/pull/18178)) + +- fix: ignore false-positive errors of `$inspect` dependencies ([#18106](https://github.com/sveltejs/svelte/pull/18106)) + ## 5.55.5 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index a4ae208913..51aed56a3a 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.55.5", + "version": "5.55.6", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 04b0b0398a..b11e7635c7 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.55.5'; +export const VERSION = '5.55.6'; export const PUBLIC_VERSION = '5'; From 547853e2406a2147ad7fb5ffeba95b01bd9642da Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 14 May 2026 20:51:28 +0200 Subject: [PATCH 19/32] Merge commit from fork * fix: disallow empty attribute names during SSR * Update packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte Co-authored-by: Conduitry --------- Co-authored-by: Elliott Johnson Co-authored-by: Conduitry --- .changeset/famous-webs-flash.md | 5 +++++ packages/svelte/src/internal/server/index.js | 2 +- .../spread-attributes-event-handler-xss/main.svelte | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/famous-webs-flash.md diff --git a/.changeset/famous-webs-flash.md b/.changeset/famous-webs-flash.md new file mode 100644 index 0000000000..75a01f689a --- /dev/null +++ b/.changeset/famous-webs-flash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: disallow empty attribute names during SSR diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 34d0133a31..12f76f188e 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -151,7 +151,7 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) { // omit functions, internal svelte properties and invalid attribute names if (typeof attrs[name] === 'function') continue; if (name[0] === '$' && name[1] === '$') continue; // faster than name.startsWith('$$') - if (INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue; + if (name === '' || INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue; var value = attrs[name]; var lower = name.toLowerCase(); diff --git a/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte index 2a8c17f048..e47a19408e 100644 --- a/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte +++ b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte @@ -5,7 +5,12 @@ onclick: 'alert(1)', onerror: 'alert(1)', onfocus: 'alert(1)', - onmouseover: 'alert(1)' + onmouseover: 'alert(1)', + ' onload': 'alert(1)', + '\tonload': 'alert(1)', + '\u00a0onload': 'alert(1)', + "\t": "/onmouseover=alert(1)//", + "": "/onmouseover=alert(1)//" }; From d2375e2ebcab5c88feb5652f1a9d621b8f06b259 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 14 May 2026 20:51:56 +0200 Subject: [PATCH 20/32] Merge commit from fork --- .changeset/proud-dogs-go.md | 5 ++++ packages/svelte/src/utils.js | 2 +- packages/svelte/src/utils.test.ts | 50 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .changeset/proud-dogs-go.md create mode 100644 packages/svelte/src/utils.test.ts diff --git a/.changeset/proud-dogs-go.md b/.changeset/proud-dogs-go.md new file mode 100644 index 0000000000..c728466138 --- /dev/null +++ b/.changeset/proud-dogs-go.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: harden regex diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 57561e6dc7..54757a6f13 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -491,7 +491,7 @@ export function is_raw_text_element(name) { // Rejects strings containing whitespace, quotes, angle brackets, slashes, equals, // or other characters that could break out of a tag-name token and enable markup injection. export const REGEX_VALID_TAG_NAME = - /^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9.\-_\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]+)*$/u; + /^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9.\-_\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]*)?$/u; /** * Prevent devtools trying to make `location` a clickable link by inserting a zero-width space diff --git a/packages/svelte/src/utils.test.ts b/packages/svelte/src/utils.test.ts new file mode 100644 index 0000000000..02175ae93f --- /dev/null +++ b/packages/svelte/src/utils.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; +import { REGEX_VALID_TAG_NAME } from './utils'; + +test('REGEX_VALID_TAG_NAME accepts common HTML tag names', () => { + const common_html_tag_names = ['div', 'span', 'button', 'input', 'svg', 'math', 'a']; + + for (const tag_name of common_html_tag_names) { + expect(REGEX_VALID_TAG_NAME.test(tag_name)).toBe(true); + } +}); + +test('REGEX_VALID_TAG_NAME accepts basic custom element names', () => { + const valid_custom_tag_names = ['my-element', 'x-foo', 'todo-item', 'my-element2']; + + for (const tag_name of valid_custom_tag_names) { + expect(REGEX_VALID_TAG_NAME.test(tag_name)).toBe(true); + } +}); + +test('REGEX_VALID_TAG_NAME accepts spec-allowed custom element characters', () => { + const valid_custom_tag_names = [ + 'x-foo.bar', + 'x-foo_bar', + 'x-foo\u00B7bar', + 'x-foo\u00FCbar', + 'x-foo\u{1F600}bar', + 'x-' + ]; + + for (const tag_name of valid_custom_tag_names) { + expect(REGEX_VALID_TAG_NAME.test(tag_name)).toBe(true); + } +}); + +test('REGEX_VALID_TAG_NAME rejects invalid tag names', () => { + const invalid_tag_names = ['', '1', 'x\u00FC', '-x-foo', '1foo', 'x-foo bar', 'x-foo/', 'x-foo>']; + + for (const tag_name of invalid_tag_names) { + expect(REGEX_VALID_TAG_NAME.test(tag_name)).toBe(false); + } +}); + +test('REGEX_VALID_TAG_NAME no ReDoS', () => { + const before = performance.now(); + REGEX_VALID_TAG_NAME.test('a-----------------------------------!'); + const after = performance.now(); + if (after - before > 10) { + throw new Error(`REGEX_VALID_TAG_NAME is vulnerable to ReDoS`); + } +}); From a16ebc67bbcf8f708360195687e1b2719463e1a4 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 14 May 2026 12:52:15 -0600 Subject: [PATCH 21/32] Merge commit from fork https://github.com/sveltejs/svelte/security/advisories/GHSA-f3cj-j4f6-wq85 Co-authored-by: Simon Holthausen --- .changeset/blue-lions-call.md | 5 +++ .../svelte/src/internal/server/hydratable.js | 9 +++-- .../src/internal/server/hydratable.test.ts | 33 +++++++++++++++++++ playgrounds/sandbox/ssr-dev.js | 5 +-- playgrounds/sandbox/ssr-prod.js | 5 +-- 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 .changeset/blue-lions-call.md create mode 100644 packages/svelte/src/internal/server/hydratable.test.ts diff --git a/.changeset/blue-lions-call.md b/.changeset/blue-lions-call.md new file mode 100644 index 0000000000..f9ddb88505 --- /dev/null +++ b/.changeset/blue-lions-call.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent XSS on `hydratable` from user contents diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 81f59ab2fd..be75288604 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -3,7 +3,6 @@ import { async_mode_flag } from '../flags/index.js'; import { get_render_context } from './render-context.js'; import * as e from './errors.js'; import * as devalue from 'devalue'; -import { get_stack } from '../shared/dev.js'; import { DEV } from 'esm-env'; import { get_user_code_location } from './dev.js'; @@ -65,7 +64,13 @@ function encode(key, value, unresolved) { const placeholder = `"${uid++}"`; const p = value .then((v) => { - entry.serialized = entry.serialized.replace(placeholder, `r(${uneval(v)})`); + entry.serialized = entry.serialized.replace( + placeholder, + // use the function form here to prevent any string replacement characters from being interpreted + // in `v`, as it's potentially user-controlled and therefore potentially malicious. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement + () => `r(${uneval(v)})` + ); }) .catch((devalue_error) => e.hydratable_serialization_failed( diff --git a/packages/svelte/src/internal/server/hydratable.test.ts b/packages/svelte/src/internal/server/hydratable.test.ts new file mode 100644 index 0000000000..8dd54c8f1d --- /dev/null +++ b/packages/svelte/src/internal/server/hydratable.test.ts @@ -0,0 +1,33 @@ +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { Renderer } from './renderer.js'; +import type { Component } from 'svelte'; +import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; +import { hydratable } from './hydratable.js'; + +beforeAll(() => { + enable_async_mode_flag(); +}); + +afterAll(() => { + disable_async_mode_flag(); +}); + +test('treats replacement tokens in hydratable promise values as literals', async () => { + const component = (renderer: Renderer) => { + hydratable('key', () => Promise.resolve(`$'`)); + renderer.child(async () => { + await Promise.resolve(); + }); + renderer.push('ok'); + }; + + const { head } = await Renderer.render(component as unknown as Component); + const script_match = head.match(/]*)?>([\s\S]*)<\/script>/); + + expect(script_match, 'expected hydratable script in head output').toBeTruthy(); + + const script_content = script_match![1]; + expect(script_content).toContain('const h = (window.__svelte ??= {}).h ??= new Map();'); + expect(script_content).toContain('r("$\'")'); + expect(script_content).toMatch(/\[\s*"key"\s*,\s*r\("\$'"\)\s*\]/); +}); diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 8a0c063d47..b770848ad1 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -29,8 +29,9 @@ polka() const { head, body } = await render(App); const html = transformed_template - .replace(``, head) - .replace(``, body) + // use function form to prevent any string replacement characters from being interpreted + .replace(``, () => head) + .replace(``, () => body) // check that Safari doesn't break hydration .replaceAll('+636-555-3226', '+636-555-3226'); diff --git a/playgrounds/sandbox/ssr-prod.js b/playgrounds/sandbox/ssr-prod.js index 0c760188d4..e3ece4d5be 100644 --- a/playgrounds/sandbox/ssr-prod.js +++ b/playgrounds/sandbox/ssr-prod.js @@ -9,8 +9,9 @@ const { head, body } = await render(App); const rendered = fs .readFileSync(path.resolve('./dist/client/index.html'), 'utf-8') - .replace(``, body) - .replace(``, head); + // use function form to prevent any string replacement characters from being interpreted + .replace(``, () => body) + .replace(``, () => head); const types = { '.js': 'application/javascript', From e1cbbd96441e82c9eb8a23a2903c0d06d3cda991 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 14 May 2026 12:59:01 -0600 Subject: [PATCH 22/32] Merge commit from fork * fix: move Svelte runtime properties to symbols Prevents DOM clobbering * shorten names --------- Co-authored-by: Simon Holthausen --- .changeset/six-snails-sell.md | 5 ++++ .../svelte/src/internal/client/constants.js | 5 ++++ .../client/dom/elements/attributes.js | 23 +++++++++++++------ .../client/dom/elements/bindings/shared.js | 10 ++++---- .../src/internal/client/dom/elements/class.js | 7 +++--- .../src/internal/client/dom/elements/misc.js | 4 ++-- .../src/internal/client/dom/elements/style.js | 7 +++--- .../src/internal/client/dom/operations.js | 23 ++++++++++--------- packages/svelte/src/internal/client/render.js | 9 ++++---- .../_config.js | 23 +++++++++++++++++++ .../main.svelte | 20 ++++++++++++++++ 11 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 .changeset/six-snails-sell.md create mode 100644 packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/_config.js create mode 100644 packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/main.svelte diff --git a/.changeset/six-snails-sell.md b/.changeset/six-snails-sell.md new file mode 100644 index 0000000000..6c61546e86 --- /dev/null +++ b/.changeset/six-snails-sell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: move Svelte runtime properties to symbols diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index f92fba73be..043b50b4b2 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -63,6 +63,11 @@ export const STATE_SYMBOL = Symbol('$state'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); +export const ATTRIBUTES_CACHE = Symbol('attributes'); +export const CLASS_CACHE = Symbol('class'); +export const STYLE_CACHE = Symbol('style'); +export const TEXT_CACHE = Symbol('text'); +export const FORM_RESET_HANDLER = Symbol('form reset'); /** An anchor might change, via this symbol on the original anchor we can tell HMR about the updated anchor */ export const HMR_ANCHOR = Symbol('hmr anchor'); diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 0cec01191a..a193a70dd5 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -5,7 +5,12 @@ import { get_descriptors, get_prototype_of } from '../../../shared/utils.js'; import { create_event, delegate, delegated, event, event_symbol } from './events.js'; import { add_form_reset_listener, autofocus } from './misc.js'; import * as w from '../../warnings.js'; -import { IS_XHTML, LOADING_ATTR_SYMBOL } from '#client/constants'; +import { + ATTRIBUTES_CACHE, + FORM_RESET_HANDLER, + IS_XHTML, + LOADING_ATTR_SYMBOL +} from '#client/constants'; import { queue_micro_task } from '../task.js'; import { is_capture_event, can_delegate_event, normalize_attribute } from '../../../../utils.js'; import { @@ -69,8 +74,7 @@ export function remove_input_defaults(input) { } }; - // @ts-expect-error - input.__on_r = remove_defaults; + /** @type {any} */ (input)[FORM_RESET_HANDLER] = remove_defaults; queue_micro_task(remove_defaults); add_form_reset_listener(); } @@ -561,8 +565,7 @@ export function attribute_effect( */ function get_attributes(element) { return /** @type {Record} **/ ( - // @ts-expect-error - element.__attributes ??= { + /** @type {any} */ (element)[ATTRIBUTES_CACHE] ??= { [IS_CUSTOM_ELEMENT]: element.nodeName.includes('-'), [IS_HTML]: element.namespaceURI === NAMESPACE_HTML } @@ -583,13 +586,19 @@ function get_setters(element) { var proto = element; // In the case of custom elements there might be setters on the instance var element_proto = Element.prototype; - // Stop at Element, from there on there's only unnecessary setters we're not interested in + // Stop at Element, from there on there's only unnecessary (and dangerous, like innerHTML) setters we're not interested in // Do not use constructor.name here as that's unreliable in some browser environments while (element_proto !== proto) { descriptors = get_descriptors(proto); for (var key in descriptors) { - if (descriptors[key].set) { + if ( + descriptors[key].set && + // better safe than sorry, we don't want spread attributes to mess with HTML content + key !== 'innerHTML' && + key !== 'textContent' && + key !== 'innerText' + ) { setters.push(key); } } diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js index aa083776a5..303890adec 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js @@ -5,6 +5,7 @@ import { set_active_effect, set_active_reaction } from '../../../runtime.js'; +import { FORM_RESET_HANDLER } from '../../../constants.js'; import { add_form_reset_listener } from '../misc.js'; /** @@ -58,18 +59,15 @@ export function without_reactive_context(fn) { */ export function listen_to_event_and_reset_event(element, event, handler, on_reset = handler) { element.addEventListener(event, () => without_reactive_context(handler)); - // @ts-expect-error - const prev = element.__on_r; + const prev = /** @type {any} */ (element)[FORM_RESET_HANDLER]; if (prev) { // special case for checkbox that can have multiple binds (group & checked) - // @ts-expect-error - element.__on_r = () => { + /** @type {any} */ (element)[FORM_RESET_HANDLER] = () => { prev(); on_reset(true); }; } else { - // @ts-expect-error - element.__on_r = () => on_reset(true); + /** @type {any} */ (element)[FORM_RESET_HANDLER] = () => on_reset(true); } add_form_reset_listener(); diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index 038ce33f3e..6478707095 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -1,4 +1,5 @@ import { to_class } from '../../../shared/attributes.js'; +import { CLASS_CACHE } from '../../constants.js'; import { hydrating } from '../hydration.js'; /** @@ -11,8 +12,7 @@ import { hydrating } from '../hydration.js'; * @returns {Record | undefined} */ export function set_class(dom, is_html, value, hash, prev_classes, next_classes) { - // @ts-expect-error need to add __className to patched prototype - var prev = dom.__className; + var prev = /** @type {any} */ (dom)[CLASS_CACHE]; if ( hydrating || @@ -35,8 +35,7 @@ export function set_class(dom, is_html, value, hash, prev_classes, next_classes) } } - // @ts-expect-error need to add __className to patched prototype - dom.__className = value; + /** @type {any} */ (dom)[CLASS_CACHE] = value; } else if (next_classes && prev_classes !== next_classes) { for (var key in next_classes) { var is_present = !!next_classes[key]; diff --git a/packages/svelte/src/internal/client/dom/elements/misc.js b/packages/svelte/src/internal/client/dom/elements/misc.js index f350d1df8c..ad34ab9c1d 100644 --- a/packages/svelte/src/internal/client/dom/elements/misc.js +++ b/packages/svelte/src/internal/client/dom/elements/misc.js @@ -1,6 +1,7 @@ import { hydrating } from '../hydration.js'; import { clear_text_content, get_first_child } from '../operations.js'; import { queue_micro_task } from '../task.js'; +import { FORM_RESET_HANDLER } from '../../constants.js'; /** * @param {HTMLElement} dom @@ -45,8 +46,7 @@ export function add_form_reset_listener() { Promise.resolve().then(() => { if (!evt.defaultPrevented) { for (const e of /**@type {HTMLFormElement} */ (evt.target).elements) { - // @ts-expect-error - e.__on_r?.(); + /** @type {any} */ (e)[FORM_RESET_HANDLER]?.(); } } }); diff --git a/packages/svelte/src/internal/client/dom/elements/style.js b/packages/svelte/src/internal/client/dom/elements/style.js index 3e05eec30e..740c225f29 100644 --- a/packages/svelte/src/internal/client/dom/elements/style.js +++ b/packages/svelte/src/internal/client/dom/elements/style.js @@ -1,4 +1,5 @@ import { to_style } from '../../../shared/attributes.js'; +import { STYLE_CACHE } from '../../constants.js'; import { hydrating } from '../hydration.js'; /** @@ -28,8 +29,7 @@ function update_styles(dom, prev = {}, next, priority) { * @param {Record | [Record, Record]} [next_styles] */ export function set_style(dom, value, prev_styles, next_styles) { - // @ts-expect-error - var prev = dom.__style; + var prev = /** @type {any} */ (dom)[STYLE_CACHE]; if (hydrating || prev !== value) { var next_style_attr = to_style(value, next_styles); @@ -42,8 +42,7 @@ export function set_style(dom, value, prev_styles, next_styles) { } } - // @ts-expect-error - dom.__style = value; + /** @type {any} */ (dom)[STYLE_CACHE] = value; } else if (next_styles) { if (Array.isArray(next_styles)) { update_styles(dom, prev_styles?.[0], next_styles[0]); diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 4036aa2d61..f6d05162ca 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -5,7 +5,14 @@ import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; import { active_effect } from '../runtime.js'; import { async_mode_flag } from '../../flags/index.js'; -import { TEXT_NODE, REACTION_RAN } from '#client/constants'; +import { + ATTRIBUTES_CACHE, + CLASS_CACHE, + REACTION_RAN, + STYLE_CACHE, + TEXT_CACHE, + TEXT_NODE +} from '#client/constants'; import { eager_block_effects } from '../reactivity/batch.js'; import { NAMESPACE_HTML } from '../../../constants.js'; @@ -48,21 +55,15 @@ export function init_operations() { if (is_extensible(element_prototype)) { // the following assignments improve perf of lookups on DOM nodes - // @ts-expect-error - element_prototype.__click = undefined; - // @ts-expect-error - element_prototype.__className = undefined; - // @ts-expect-error - element_prototype.__attributes = null; - // @ts-expect-error - element_prototype.__style = undefined; + /** @type {any} */ (element_prototype)[CLASS_CACHE] = undefined; + /** @type {any} */ (element_prototype)[ATTRIBUTES_CACHE] = null; + /** @type {any} */ (element_prototype)[STYLE_CACHE] = undefined; // @ts-expect-error element_prototype.__e = undefined; } if (is_extensible(text_prototype)) { - // @ts-expect-error - text_prototype.__t = undefined; + /** @type {any} */ (text_prototype)[TEXT_CACHE] = undefined; } if (DEV) { diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index cb152ed9c1..50832fb3ff 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -23,7 +23,7 @@ import * as w from './warnings.js'; import * as e from './errors.js'; import { assign_nodes } from './dom/template.js'; import { is_passive_event } from '../../utils.js'; -import { COMMENT_NODE, STATE_SYMBOL } from './constants.js'; +import { COMMENT_NODE, STATE_SYMBOL, TEXT_CACHE } from './constants.js'; import { boundary } from './dom/blocks/boundary.js'; /** @@ -46,10 +46,9 @@ export function set_should_intro(value) { export function set_text(text, value) { // For objects, we apply string coercion (which might make things like $state array references in the template reactive) before diffing var str = value == null ? '' : typeof value === 'object' ? `${value}` : value; - // @ts-expect-error - if (str !== (text.__t ??= text.nodeValue)) { - // @ts-expect-error - text.__t = str; + // prettier-ignore + if (str !== (/** @type {any} */ (text)[TEXT_CACHE] ??= text.nodeValue)) { + /** @type {any} */ (text)[TEXT_CACHE] = str; text.nodeValue = `${str}`; } } diff --git a/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/_config.js b/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/_config.js new file mode 100644 index 0000000000..62248851db --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/_config.js @@ -0,0 +1,23 @@ +import { assert_ok, test } from '../../assert'; + +export default test({ + async test({ assert, target, waitUntil, window }) { + const form = target.querySelector('form'); + const button = target.querySelector('button'); + const [i1, i2, i3] = target.querySelectorAll('input'); + assert_ok(form); + assert_ok(button); + + assert.equal(form.id, 'initial-form'); + assert.equal(form.className, 'first'); + assert.equal(window.getComputedStyle(form).backgroundColor, 'rgb(255, 0, 0)'); + + button.click(); + await waitUntil(() => form.id === 'updated-form'); + + assert.equal(form.id, 'updated-form'); + assert.equal(form.className, 'second'); + assert.equal(i3.id, '', 'input clobbered form'); + assert.equal(window.getComputedStyle(form).backgroundColor, 'rgb(0, 0, 255)'); + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/main.svelte b/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/main.svelte new file mode 100644 index 0000000000..60e9b80e81 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/dom-clobbering-cache-symbols-spread/main.svelte @@ -0,0 +1,20 @@ + + + + +
+ + + + +
From 05523088173e10af0753877af6936088de924833 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 14 May 2026 15:00:51 -0400 Subject: [PATCH 23/32] chore: bump devalue (#18219) --- .changeset/eight-things-join.md | 5 +++++ packages/svelte/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/eight-things-join.md diff --git a/.changeset/eight-things-join.md b/.changeset/eight-things-join.md new file mode 100644 index 0000000000..678d63db33 --- /dev/null +++ b/.changeset/eight-things-join.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: bump devalue diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 51aed56a3a..7e9521bb57 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -176,7 +176,7 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e2be208b6..1bfee5c769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.6.4 - version: 5.6.4 + specifier: ^5.8.1 + version: 5.8.1 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1418,8 +1418,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.6.4: - resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -3786,7 +3786,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.6.4: {} + devalue@5.8.1: {} dir-glob@3.0.1: dependencies: From 4d8f99a2709e3c02e48d8bc6c77458f4ba49d0e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 13:06:06 -0600 Subject: [PATCH 24/32] Version Packages (#18220) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.55.7 ### Patch Changes - fix: prevent XSS on `hydratable` from user contents ([`a16ebc67bbcf8f708360195687e1b2719463e1a4`](https://github.com/sveltejs/svelte/commit/a16ebc67bbcf8f708360195687e1b2719463e1a4)) - chore: bump devalue ([#18219](https://github.com/sveltejs/svelte/pull/18219)) - fix: disallow empty attribute names during SSR ([`547853e2406a2147ad7fb5ffeba95b01bd9642da`](https://github.com/sveltejs/svelte/commit/547853e2406a2147ad7fb5ffeba95b01bd9642da)) - fix: harden regex ([`d2375e2ebcab5c88feb5652f1a9d621b8f06b259`](https://github.com/sveltejs/svelte/commit/d2375e2ebcab5c88feb5652f1a9d621b8f06b259)) - fix: move Svelte runtime properties to symbols ([`e1cbbd96441e82c9eb8a23a2903c0d06d3cda991`](https://github.com/sveltejs/svelte/commit/e1cbbd96441e82c9eb8a23a2903c0d06d3cda991)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/blue-lions-call.md | 5 ----- .changeset/eight-things-join.md | 5 ----- .changeset/famous-webs-flash.md | 5 ----- .changeset/proud-dogs-go.md | 5 ----- .changeset/six-snails-sell.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 8 files changed, 16 insertions(+), 27 deletions(-) delete mode 100644 .changeset/blue-lions-call.md delete mode 100644 .changeset/eight-things-join.md delete mode 100644 .changeset/famous-webs-flash.md delete mode 100644 .changeset/proud-dogs-go.md delete mode 100644 .changeset/six-snails-sell.md diff --git a/.changeset/blue-lions-call.md b/.changeset/blue-lions-call.md deleted file mode 100644 index f9ddb88505..0000000000 --- a/.changeset/blue-lions-call.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: prevent XSS on `hydratable` from user contents diff --git a/.changeset/eight-things-join.md b/.changeset/eight-things-join.md deleted file mode 100644 index 678d63db33..0000000000 --- a/.changeset/eight-things-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: bump devalue diff --git a/.changeset/famous-webs-flash.md b/.changeset/famous-webs-flash.md deleted file mode 100644 index 75a01f689a..0000000000 --- a/.changeset/famous-webs-flash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: disallow empty attribute names during SSR diff --git a/.changeset/proud-dogs-go.md b/.changeset/proud-dogs-go.md deleted file mode 100644 index c728466138..0000000000 --- a/.changeset/proud-dogs-go.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: harden regex diff --git a/.changeset/six-snails-sell.md b/.changeset/six-snails-sell.md deleted file mode 100644 index 6c61546e86..0000000000 --- a/.changeset/six-snails-sell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: move Svelte runtime properties to symbols diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index e5ba5737c4..1255d8c6a0 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.55.7 + +### Patch Changes + +- fix: prevent XSS on `hydratable` from user contents ([`a16ebc67bbcf8f708360195687e1b2719463e1a4`](https://github.com/sveltejs/svelte/commit/a16ebc67bbcf8f708360195687e1b2719463e1a4)) + +- chore: bump devalue ([#18219](https://github.com/sveltejs/svelte/pull/18219)) + +- fix: disallow empty attribute names during SSR ([`547853e2406a2147ad7fb5ffeba95b01bd9642da`](https://github.com/sveltejs/svelte/commit/547853e2406a2147ad7fb5ffeba95b01bd9642da)) + +- fix: harden regex ([`d2375e2ebcab5c88feb5652f1a9d621b8f06b259`](https://github.com/sveltejs/svelte/commit/d2375e2ebcab5c88feb5652f1a9d621b8f06b259)) + +- fix: move Svelte runtime properties to symbols ([`e1cbbd96441e82c9eb8a23a2903c0d06d3cda991`](https://github.com/sveltejs/svelte/commit/e1cbbd96441e82c9eb8a23a2903c0d06d3cda991)) + ## 5.55.6 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 7e9521bb57..2f0743641f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.55.6", + "version": "5.55.7", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index b11e7635c7..6b0ed0aea8 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.55.6'; +export const VERSION = '5.55.7'; export const PUBLIC_VERSION = '5'; From eb10a70cf1f003c3e93f3c4ecc9749870e7581c6 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 18 May 2026 17:36:17 +0200 Subject: [PATCH 25/32] fix: unset context synchronously in `run` (#18236) Our `run` function which executes top level awaits (and synchronous statements in-between/after) did not unset the context in time in case the function returns an async value. In that case the context was still around until the that promise resolves, which can be too late because unrelated things can be intertwined with the batch. The test shows this: Without the fix, the unrelated count incrementation would not update the view until the top level awaits in the child are done. In the test this just shows as a delayed visual update, but it also can result in stale roots as shown in https://github.com/sveltejs/svelte/issues/18221#issuecomment-4470921077 --- .changeset/tangy-icons-dig.md | 5 ++++ .../src/internal/client/reactivity/async.js | 23 ++++++++++------ .../async-run-isolated-batch/Child.svelte | 7 +++++ .../async-run-isolated-batch/_config.js | 26 +++++++++++++++++++ .../async-run-isolated-batch/main.svelte | 23 ++++++++++++++++ 5 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 .changeset/tangy-icons-dig.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte diff --git a/.changeset/tangy-icons-dig.md b/.changeset/tangy-icons-dig.md new file mode 100644 index 0000000000..6d87912bbb --- /dev/null +++ b/.changeset/tangy-icons-dig.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: unset context synchronously in `run` diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index c1d4cbcd67..12c5e9baa5 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -303,15 +303,21 @@ export function run(thunks) { .then(() => { restore(); - if (errored) { - throw errored.error; + try { + if (errored) { + throw errored.error; + } + + if (aborted(active)) { + throw STALE_REACTION; + } + + return fn(); + } finally { + // We gotta unset context directly in case the function returns a promise, in which case + // unset_context in .finally() would be too late ... + unset_context(); } - - if (aborted(active)) { - throw STALE_REACTION; - } - - return fn(); }) .catch(handle_error); @@ -320,6 +326,7 @@ export function run(thunks) { promise.finally(() => { blocker.settled = true; + // ... but we also need it after such a promise has resolved in case it restores our context unset_context(); }); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte new file mode 100644 index 0000000000..6f84dc319e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte @@ -0,0 +1,7 @@ + + +{x} {y} diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js new file mode 100644 index 0000000000..a0dc38c660 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js @@ -0,0 +1,26 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [show, resolve, count] = target.querySelectorAll('button'); + + show.click(); + await tick(); + resolve.click(); + await tick(); + count.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '1 2 ' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte new file mode 100644 index 0000000000..0a5346cc47 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte @@ -0,0 +1,23 @@ + + + + +{#if show} + +{/if} + + + + From 1e899cba354d2b25593afc1bbf04bf3555b5d26a Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 18 May 2026 17:36:41 +0200 Subject: [PATCH 26/32] fix: execute uninitialized derived even if it's destroyed (#18228) Turns out there are a few unavoidable cases where we have to execute the derived even if we otherwise wouldn't, because of its lazy nature. Fixes #18139 --- .changeset/hungry-ads-beam.md | 5 +++ .../internal/client/reactivity/deriveds.js | 7 +++- .../Child.svelte | 10 ++++++ .../_config.js | 32 +++++++++++++++++++ .../main.svelte | 11 +++++++ 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/hungry-ads-beam.md create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/main.svelte diff --git a/.changeset/hungry-ads-beam.md b/.changeset/hungry-ads-beam.md new file mode 100644 index 0000000000..260d177119 --- /dev/null +++ b/.changeset/hungry-ads-beam.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: execute uninitialized derived even if it's destroyed diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 83e23dfab6..75be018552 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -338,7 +338,12 @@ export function execute_derived(derived) { var prev_active_effect = active_effect; var parent = derived.parent; - if (!is_destroying_effect && parent !== null && (parent.f & (DESTROYED | INERT)) !== 0) { + if ( + !is_destroying_effect && + parent !== null && + derived.v !== UNINITIALIZED && // if it was never evaluated before, it's guaranteed to fail downstream, so we try to execute instead + (parent.f & (DESTROYED | INERT)) !== 0 + ) { w.derived_inert(); return derived.v; diff --git a/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/Child.svelte b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/Child.svelte new file mode 100644 index 0000000000..20bd606d9b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/Child.svelte @@ -0,0 +1,10 @@ + + +
+ hello +
diff --git a/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/_config.js new file mode 100644 index 0000000000..734c84a0c4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from 'svelte'; +import { raf } from '../../../animation-helpers'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [fly_in, fly_out] = target.querySelectorAll('button'); + + fly_in.click(); + flushSync(); + raf.tick(25); + assert.htmlEqual( + target.innerHTML, + ` + + +
hello
+ ` + ); + + fly_out.click(); + flushSync(); + raf.tick(50); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/main.svelte new file mode 100644 index 0000000000..4fc3395af8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-derived-uninitialized/main.svelte @@ -0,0 +1,11 @@ + + + + + +{#if show} + +{/if} From dc1f037fa67bf2fbdeb1c5f18b2b1f7d023077e3 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 18 May 2026 17:37:01 +0200 Subject: [PATCH 27/32] fix: don't run teardown effects when deriveds are unfreezed (#18227) The logic was flawed - a teardown effect only has a teardown function but not `fn` property, but unfreeze thought that everything with a `teardown` needs to be unfreezed Helps with #18221 (though likely doesn't fix it completely, at least not the more general `batch.#roots`problems) --- .changeset/slimy-olives-cross.md | 5 +++++ .../internal/client/reactivity/deriveds.js | 6 +++--- .../async-state-eager-const/_config.js | 21 +++++++++++++++++++ .../async-state-eager-const/main.svelte | 21 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/slimy-olives-cross.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-eager-const/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-eager-const/main.svelte diff --git a/.changeset/slimy-olives-cross.md b/.changeset/slimy-olives-cross.md new file mode 100644 index 0000000000..3309e2deab --- /dev/null +++ b/.changeset/slimy-olives-cross.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't run teardown effects when deriveds are unfreezed diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 75be018552..8d240bca31 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -452,8 +452,8 @@ export function freeze_derived_effects(derived) { // make it a noop so it doesn't get called again if the derived // is unfrozen. we don't set it to `null`, because the existence // of a teardown function is what determines whether the - // effect runs again during unfreezing - e.teardown = noop; + // effect runs again during unfreezing (but not for teardown-only effects) + if (e.fn !== null) e.teardown = noop; e.ac = null; remove_reactions(e, 0); @@ -471,7 +471,7 @@ export function unfreeze_derived_effects(derived) { for (const e of derived.effects) { // if the effect was previously frozen — indicated by the presence // of a teardown function — unfreeze it - if (e.teardown) { + if (e.teardown && e.fn !== null) { update_effect(e); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/_config.js new file mode 100644 index 0000000000..1511d443e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { dev: true }, // for testing that teardown effect in eager $.get(loaded) doesn't lead to a crash (because it means REACTION_RAN is set, which means unfreeze_derived runs) + async test({ assert, target }) { + const [count, shift] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

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

0 (...)

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

1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/main.svelte new file mode 100644 index 0000000000..f0e2f1cdac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager-const/main.svelte @@ -0,0 +1,21 @@ + + + + + + + {@const loaded = $state.eager(count) === count} +

{await push(count)} {loaded ? '' : '(...)'}

+ + {#snippet pending()}{/snippet} +
From 2bc3592eb1ba1b37bde6c893e84b28e2f630db59 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 18 May 2026 11:53:31 -0400 Subject: [PATCH 28/32] fix: use named symbols everywhere (#18238) This isn't _really_ a fix since these should never surface to the user, but it's useful for debugging when they do, as in https://github.com/sveltejs/kit/pull/15779. Instead of seeing `Symbol()` we see e.g. `Symbol(uninitialized)` which makes it easier to understand where a bug is coming from. --- .changeset/red-moles-talk.md | 5 +++++ packages/svelte/src/constants.js | 2 +- packages/svelte/src/internal/client/reactivity/store.js | 2 +- packages/svelte/src/reactivity/url-search-params.js | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/red-moles-talk.md diff --git a/.changeset/red-moles-talk.md b/.changeset/red-moles-talk.md new file mode 100644 index 0000000000..7e09dc9b9a --- /dev/null +++ b/.changeset/red-moles-talk.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: use named symbols everywhere diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js index a3a109b943..1e721b7d30 100644 --- a/packages/svelte/src/constants.js +++ b/packages/svelte/src/constants.js @@ -32,7 +32,7 @@ export const ELEMENT_IS_NAMESPACED = 1; export const ELEMENT_PRESERVE_ATTRIBUTE_CASE = 1 << 1; export const ELEMENT_IS_INPUT = 1 << 2; -export const UNINITIALIZED = Symbol(); +export const UNINITIALIZED = Symbol('uninitialized'); // Dev-time component properties export const FILENAME = Symbol('filename'); diff --git a/packages/svelte/src/internal/client/reactivity/store.js b/packages/svelte/src/internal/client/reactivity/store.js index 7124e23db8..726765b619 100644 --- a/packages/svelte/src/internal/client/reactivity/store.js +++ b/packages/svelte/src/internal/client/reactivity/store.js @@ -21,7 +21,7 @@ export let legacy_is_updating_store = false; */ let is_store_binding = false; -let IS_UNMOUNTED = Symbol(); +let IS_UNMOUNTED = Symbol('unmounted'); /** * Gets the current value of a store. If the store isn't subscribed to yet, it will create a proxy diff --git a/packages/svelte/src/reactivity/url-search-params.js b/packages/svelte/src/reactivity/url-search-params.js index 2381e11875..2e70bf518d 100644 --- a/packages/svelte/src/reactivity/url-search-params.js +++ b/packages/svelte/src/reactivity/url-search-params.js @@ -4,7 +4,7 @@ import { tag } from '../internal/client/dev/tracing.js'; import { get } from '../internal/client/runtime.js'; import { get_current_url } from './url.js'; -export const REPLACE = Symbol(); +export const REPLACE = Symbol('replace'); /** * A reactive version of the built-in [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) object. From ca3f35bf7d3eeea6d0b5f477d208c3fd9c19988c Mon Sep 17 00:00:00 2001 From: OfirHaf Date: Mon, 18 May 2026 18:54:31 +0300 Subject: [PATCH 29/32] fix(print): handle svelte:body and fix keyframe percentage double-printing (#18234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #18206 and fixes #18207 — both are printer bugs in `packages/svelte/src/compiler/print/index.js`. ## Changes ### Fix 1: `svelte:body` crashes the printer (#18206) `SvelteBody` was missing from the visitor map, causing a crash with `Error: Not implemented: SvelteBody`. Added the handler (same one-liner pattern as `SvelteDocument`, `SvelteHead`, etc.) and added `SvelteBody` to the `is_block_element` check so whitespace is handled consistently. ### Fix 2: Keyframe percent stops print as `0%%` (#18207) `Percentage.value` already includes the `%` sign (captured by `/\d+(\.\d+)?%/y`), but the printer was writing `` `${node.value}%` `` — appending a second `%`. Changed to `context.write(node.value)` to match the `Nth` printer pattern directly above it. Also updated the existing `style` snapshot which had `50%%` (the bug was silently baked in), and added dedicated test samples for both fixes. --- .../fix-printer-svelte-body-keyframe-percent.md | 5 +++++ packages/svelte/src/compiler/print/index.js | 7 ++++++- .../samples/css-keyframes-percent/input.svelte | 7 +++++++ .../samples/css-keyframes-percent/output.svelte | 13 +++++++++++++ .../svelte/tests/print/samples/style/output.svelte | 2 +- .../tests/print/samples/svelte-body/input.svelte | 1 + .../tests/print/samples/svelte-body/output.svelte | 1 + 7 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-printer-svelte-body-keyframe-percent.md create mode 100644 packages/svelte/tests/print/samples/css-keyframes-percent/input.svelte create mode 100644 packages/svelte/tests/print/samples/css-keyframes-percent/output.svelte create mode 100644 packages/svelte/tests/print/samples/svelte-body/input.svelte create mode 100644 packages/svelte/tests/print/samples/svelte-body/output.svelte diff --git a/.changeset/fix-printer-svelte-body-keyframe-percent.md b/.changeset/fix-printer-svelte-body-keyframe-percent.md new file mode 100644 index 0000000000..6854468835 --- /dev/null +++ b/.changeset/fix-printer-svelte-body-keyframe-percent.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix(print): handle `svelte:body` and fix keyframe percentage double-printing diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index 26dc1b88e8..c511ee6da0 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -247,7 +247,7 @@ const css_visitors = { }, Percentage(node, context) { - context.write(`${node.value}%`); + context.write(node.value); }, PseudoClassSelector(node, context) { @@ -417,6 +417,7 @@ const svelte_visitors = (comments) => ({ const is_block_element = child_node.type === 'RegularElement' || child_node.type === 'Component' || + child_node.type === 'SvelteBody' || child_node.type === 'SvelteHead' || child_node.type === 'SvelteFragment' || child_node.type === 'SvelteBoundary' || @@ -821,6 +822,10 @@ const svelte_visitors = (comments) => ({ context.write(''); }, + SvelteBody(node, context) { + base_element(node, context, comments); + }, + SvelteBoundary(node, context) { base_element(node, context, comments); }, diff --git a/packages/svelte/tests/print/samples/css-keyframes-percent/input.svelte b/packages/svelte/tests/print/samples/css-keyframes-percent/input.svelte new file mode 100644 index 0000000000..b2a0e3aa7a --- /dev/null +++ b/packages/svelte/tests/print/samples/css-keyframes-percent/input.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/svelte/tests/print/samples/css-keyframes-percent/output.svelte b/packages/svelte/tests/print/samples/css-keyframes-percent/output.svelte new file mode 100644 index 0000000000..e35a5780fa --- /dev/null +++ b/packages/svelte/tests/print/samples/css-keyframes-percent/output.svelte @@ -0,0 +1,13 @@ + diff --git a/packages/svelte/tests/print/samples/style/output.svelte b/packages/svelte/tests/print/samples/style/output.svelte index 03168cccae..9fc5e3fbba 100644 --- a/packages/svelte/tests/print/samples/style/output.svelte +++ b/packages/svelte/tests/print/samples/style/output.svelte @@ -19,7 +19,7 @@ from { opacity: 0; } - 50%% { + 50% { opacity: 0.5; } to { diff --git a/packages/svelte/tests/print/samples/svelte-body/input.svelte b/packages/svelte/tests/print/samples/svelte-body/input.svelte new file mode 100644 index 0000000000..a4c26db5ce --- /dev/null +++ b/packages/svelte/tests/print/samples/svelte-body/input.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/print/samples/svelte-body/output.svelte b/packages/svelte/tests/print/samples/svelte-body/output.svelte new file mode 100644 index 0000000000..e13eaae979 --- /dev/null +++ b/packages/svelte/tests/print/samples/svelte-body/output.svelte @@ -0,0 +1 @@ + From f9440dc3ed28a9f1f518b0216d0e10fce00f1618 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 18:44:05 +0200 Subject: [PATCH 30/32] Version Packages (#18239) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.55.8 ### Patch Changes - fix(print): handle `svelte:body` and fix keyframe percentage double-printing ([#18234](https://github.com/sveltejs/svelte/pull/18234)) - fix: execute uninitialized derived even if it's destroyed ([#18228](https://github.com/sveltejs/svelte/pull/18228)) - fix: use named symbols everywhere ([#18238](https://github.com/sveltejs/svelte/pull/18238)) - fix: don't run teardown effects when deriveds are unfreezed ([#18227](https://github.com/sveltejs/svelte/pull/18227)) - fix: unset context synchronously in `run` ([#18236](https://github.com/sveltejs/svelte/pull/18236)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../fix-printer-svelte-body-keyframe-percent.md | 5 ----- .changeset/hungry-ads-beam.md | 5 ----- .changeset/red-moles-talk.md | 5 ----- .changeset/slimy-olives-cross.md | 5 ----- .changeset/tangy-icons-dig.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 8 files changed, 16 insertions(+), 27 deletions(-) delete mode 100644 .changeset/fix-printer-svelte-body-keyframe-percent.md delete mode 100644 .changeset/hungry-ads-beam.md delete mode 100644 .changeset/red-moles-talk.md delete mode 100644 .changeset/slimy-olives-cross.md delete mode 100644 .changeset/tangy-icons-dig.md diff --git a/.changeset/fix-printer-svelte-body-keyframe-percent.md b/.changeset/fix-printer-svelte-body-keyframe-percent.md deleted file mode 100644 index 6854468835..0000000000 --- a/.changeset/fix-printer-svelte-body-keyframe-percent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix(print): handle `svelte:body` and fix keyframe percentage double-printing diff --git a/.changeset/hungry-ads-beam.md b/.changeset/hungry-ads-beam.md deleted file mode 100644 index 260d177119..0000000000 --- a/.changeset/hungry-ads-beam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: execute uninitialized derived even if it's destroyed diff --git a/.changeset/red-moles-talk.md b/.changeset/red-moles-talk.md deleted file mode 100644 index 7e09dc9b9a..0000000000 --- a/.changeset/red-moles-talk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: use named symbols everywhere diff --git a/.changeset/slimy-olives-cross.md b/.changeset/slimy-olives-cross.md deleted file mode 100644 index 3309e2deab..0000000000 --- a/.changeset/slimy-olives-cross.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't run teardown effects when deriveds are unfreezed diff --git a/.changeset/tangy-icons-dig.md b/.changeset/tangy-icons-dig.md deleted file mode 100644 index 6d87912bbb..0000000000 --- a/.changeset/tangy-icons-dig.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: unset context synchronously in `run` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 1255d8c6a0..36528c741b 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.55.8 + +### Patch Changes + +- fix(print): handle `svelte:body` and fix keyframe percentage double-printing ([#18234](https://github.com/sveltejs/svelte/pull/18234)) + +- fix: execute uninitialized derived even if it's destroyed ([#18228](https://github.com/sveltejs/svelte/pull/18228)) + +- fix: use named symbols everywhere ([#18238](https://github.com/sveltejs/svelte/pull/18238)) + +- fix: don't run teardown effects when deriveds are unfreezed ([#18227](https://github.com/sveltejs/svelte/pull/18227)) + +- fix: unset context synchronously in `run` ([#18236](https://github.com/sveltejs/svelte/pull/18236)) + ## 5.55.7 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 2f0743641f..66a555880b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.55.7", + "version": "5.55.8", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 6b0ed0aea8..446904cd14 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.55.7'; +export const VERSION = '5.55.8'; export const PUBLIC_VERSION = '5'; From a5df6616ea6c8ee0984ff0fc7b04e51472f1d01d Mon Sep 17 00:00:00 2001 From: Sean Kenneth Doherty Date: Mon, 18 May 2026 13:53:10 -0500 Subject: [PATCH 31/32] fix: avoid unnecessary stringify in server attributes (#18232) ### 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. Fixes #10031. - [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`). ### What this changes Server attribute template generation currently wraps each dynamic expression in `$.stringify`, even when the compiler can prove the expression is a string or a known constant. This reuses the existing scope evaluation metadata so server output can avoid `$.stringify` for proven string/constant chunks while keeping it for possibly nullish unknown values. The updated snapshot covers a mixed attribute with a known string, mutable state, `null`, numeric/undefined constants, a known string-producing `typeof`, and an unknown prop value. ### Tests and linting - [x] `pnpm test snapshot -t nullish-coallescence-omittance` - [x] `pnpm test snapshot` - [x] `pnpm --filter svelte check` - [x] `pnpm lint` - [x] `pnpm prettier --check .changeset/slow-bikes-serve.md` --------- Co-authored-by: Rich Harris --- .changeset/slow-bikes-serve.md | 5 +++ .../server/visitors/shared/utils.js | 33 ++++++++++++------- .../_expected/client/index.svelte.js | 13 ++++++-- .../_expected/server/index.svelte.js | 5 +-- .../index.svelte | 4 ++- 5 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 .changeset/slow-bikes-serve.md diff --git a/.changeset/slow-bikes-serve.md b/.changeset/slow-bikes-serve.md new file mode 100644 index 0000000000..5e1d654353 --- /dev/null +++ b/.changeset/slow-bikes-serve.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: inline primitive constants in attribute values during SSR diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index 9b3ac3ad78..62e1c44094 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -229,18 +229,25 @@ export function build_attribute_value( ? node.data.replace(regex_whitespaces_strict, ' ') : node.data; } else { - expressions.push( - b.call( - '$.stringify', - transform( - /** @type {Expression} */ (context.visit(node.expression)), - node.metadata.expression - ) - ) - ); + const evaluated = context.state.scope.evaluate(node.expression); - quasi = b.quasi('', i + 1 === value.length); - quasis.push(quasi); + if (evaluated.is_known) { + quasi.value.cooked += (evaluated.value ?? '') + ''; + } else { + const expression = transform( + /** @type {Expression} */ (context.visit(node.expression)), + node.metadata.expression + ); + + expressions.push( + evaluated.is_string && evaluated.is_defined + ? expression + : b.call('$.stringify', expression) + ); + + quasi = b.quasi('', i + 1 === value.length); + quasis.push(quasi); + } } } @@ -248,7 +255,9 @@ export function build_attribute_value( quasi.value.raw = sanitize_template_string(/** @type {string} */ (quasi.value.cooked)); } - return b.template(quasis, expressions); + return expressions.length > 0 + ? b.template(quasis, expressions) + : b.literal(/** @type {string} */ (quasi.value.cooked)); } /** diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js index 5281a86582..837a7cd65a 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js @@ -1,9 +1,9 @@ import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; -var root = $.from_html(`

`, 1); +var root = $.from_html(`

`, 1); -export default function Nullish_coallescence_omittance($$anchor) { +export default function Nullish_coallescence_omittance($$anchor, $$props) { let name = 'world'; let count = $.state(0); var fragment = root(); @@ -23,7 +23,14 @@ export default function Nullish_coallescence_omittance($$anchor) { var h1_1 = $.sibling(button, 2); h1_1.textContent = 'Hello, world'; - $.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`)); + + var div = $.sibling(h1_1, 2); + + $.template_effect(() => { + $.set_text(text, `Count is ${$.get(count) ?? ''}`); + $.set_attribute(div, 'title', `Hello, world ${$.get(count) ?? ''} 1 ${typeof $$props.value} ${$$props.value ?? ''}`); + }); + $.delegated('click', button, () => $.update(count)); $.append($$anchor, fragment); } diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js index a7e580acb8..357a887c9b 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js @@ -1,8 +1,9 @@ import * as $ from 'svelte/internal/server'; -export default function Nullish_coallescence_omittance($$renderer) { +export default function Nullish_coallescence_omittance($$renderer, $$props) { let name = 'world'; let count = 0; + let { value } = $$props; - $$renderer.push(`

Hello, world!

123

Hello, world

`); + $$renderer.push(`

Hello, world!

123

Hello, world

`); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/index.svelte b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/index.svelte index a67c574fee..ed88f5a269 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/index.svelte +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/index.svelte @@ -1,8 +1,10 @@

Hello, {null}{name}!

{1 ?? 'stuff'}{2 ?? 'more stuff'}{3 ?? 'even more stuff'} -

Hello, {name ?? 'earth' ?? null}

\ No newline at end of file +

Hello, {name ?? 'earth' ?? null}

+
From a63bf862c03d5c63b73e412f2ca952cb83d444d8 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Mon, 18 May 2026 12:42:20 -0700 Subject: [PATCH 32/32] docs: clarify context state getter pattern (#18226) Closes #18022 This updates the context docs to show the getter pattern for state that may be reassigned, such as primitive state. The new example passes a function through context so children read the current value instead of capturing the initial value. This is a docs-only clarification and does not change runtime behavior. Validation: - `git diff --check` - `pnpm exec prettier --check documentation/docs/06-runtime/02-context.md` Full `pnpm test`/`pnpm lint` were not run because this only changes documentation. --------- Co-authored-by: Rich Harris --- documentation/docs/06-runtime/02-context.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/06-runtime/02-context.md b/documentation/docs/06-runtime/02-context.md index aee51c9a78..439272df8f 100644 --- a/documentation/docs/06-runtime/02-context.md +++ b/documentation/docs/06-runtime/02-context.md @@ -163,6 +163,8 @@ export const [getCounter, setCounter] = createContext(); Svelte will warn you if you get it wrong. +Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions). + ## 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: