From d16099ef0fa666f3610ef75ad9fca3252b0b47d0 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 10:58:29 +0200
Subject: [PATCH 01/29] fix: remove `WAS_MARKED` flag in favor of `Set`
(#18127)
Removes the `WAS_MARKED` logic in favor of a simple `Set` heuristic: If
`mark_reactions` goes beyond a certain count, initialize it, otherwise
keep it `null`. Balances the common case of not having many transitive
dependencies with the edge case of cyclic dependencies as was seen in
#16658 (I rechecked that reproduction and it remains fast with this
change).
I ran the benchmark against this and it doesn't hit the `Seen` heuristic
once (i.e. even the benchmark doesn't have this extreme level of
dependencies where it becomes noticeable).
Fixes #18123, supersedes #18124
---------
Co-authored-by: Rich Harris
---
.changeset/grumpy-pens-press.md | 5 +++
benchmarking/benchmarks/reactivity/index.js | 5 ++-
.../svelte/src/internal/client/constants.js | 9 ----
.../svelte/src/internal/client/dev/debug.js | 2 -
.../internal/client/reactivity/deriveds.js | 3 --
.../src/internal/client/reactivity/sources.js | 44 ++++++++++++-------
.../src/internal/client/reactivity/utils.js | 25 +----------
.../svelte/src/internal/client/runtime.js | 8 ----
8 files changed, 38 insertions(+), 63 deletions(-)
create mode 100644 .changeset/grumpy-pens-press.md
diff --git a/.changeset/grumpy-pens-press.md b/.changeset/grumpy-pens-press.md
new file mode 100644
index 0000000000..0ba87ab0c5
--- /dev/null
+++ b/.changeset/grumpy-pens-press.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: remove `WAS_MARKED` flag in favor of `Set`
diff --git a/benchmarking/benchmarks/reactivity/index.js b/benchmarking/benchmarks/reactivity/index.js
index 3fe9639376..d971b25e9b 100644
--- a/benchmarking/benchmarks/reactivity/index.js
+++ b/benchmarking/benchmarks/reactivity/index.js
@@ -13,7 +13,7 @@ import {
sbench_create_4to1,
sbench_create_signals
} from './sbench.js';
-import { fileURLToPath } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import { create_test } from './util.js';
// This benchmark has been adapted from the js-reactivity-benchmark (https://github.com/milomg/js-reactivity-benchmark)
@@ -39,7 +39,8 @@ for (const file of fs.readdirSync(`${dirname}/tests`)) {
const name = file.replace('.bench.js', '');
- const module = await import(`${dirname}/tests/${file}`);
+ const module_url = pathToFileURL(path.join(dirname, 'tests', file));
+ const module = await import(module_url.href);
const { owned, unowned } = create_test(name, module.default);
reactivity_benchmarks.push(owned, unowned);
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index e0b7a8779b..b086bfedff 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -51,15 +51,6 @@ export const EFFECT_PRESERVED = 1 << 19;
export const USER_EFFECT = 1 << 20;
export const EFFECT_OFFSCREEN = 1 << 25;
-// Flags exclusive to deriveds
-/**
- * Tells that we marked this derived and its reactions as visited during the "mark as (maybe) dirty"-phase.
- * Will be lifted during execution of the derived and during checking its dirty state (both are necessary
- * because a derived might be checked but not executed). This is a pure performance optimization flag and
- * should not be used for any other purpose!
- */
-export const WAS_MARKED = 1 << 16;
-
// Flags used for async
export const REACTION_IS_UPDATING = 1 << 21;
export const ASYNC = 1 << 22;
diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js
index 83cc510ae2..2513d15c97 100644
--- a/packages/svelte/src/internal/client/dev/debug.js
+++ b/packages/svelte/src/internal/client/dev/debug.js
@@ -15,7 +15,6 @@ import {
MAYBE_DIRTY,
RENDER_EFFECT,
ROOT_EFFECT,
- WAS_MARKED,
MANAGED_EFFECT
} from '#client/constants';
import { snapshot } from '../../shared/clone.js';
@@ -204,7 +203,6 @@ export function log_reactions(signal) {
if ((flags & DIRTY) !== 0) names.push('DIRTY');
if ((flags & MAYBE_DIRTY) !== 0) names.push('MAYBE_DIRTY');
if ((flags & CONNECTED) !== 0) names.push('CONNECTED');
- if ((flags & WAS_MARKED) !== 0) names.push('WAS_MARKED');
if ((flags & INERT) !== 0) names.push('INERT');
if ((flags & DESTROYED) !== 0) names.push('DESTROYED');
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 6b48bc8a23..728ef1d214 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -9,7 +9,6 @@ import {
EFFECT_PRESERVED,
STALE_REACTION,
ASYNC,
- WAS_MARKED,
DESTROYED,
CLEAN,
REACTION_RAN,
@@ -365,7 +364,6 @@ export function execute_derived(derived) {
stack.push(derived);
- derived.f &= ~WAS_MARKED;
destroy_derived_effects(derived);
value = update_reaction(derived);
} finally {
@@ -375,7 +373,6 @@ export function execute_derived(derived) {
}
} else {
try {
- derived.f &= ~WAS_MARKED;
destroy_derived_effects(derived);
value = update_reaction(derived);
} finally {
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 70be6489a4..006494f0b5 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -25,10 +25,7 @@ import {
MAYBE_DIRTY,
BLOCK_EFFECT,
ROOT_EFFECT,
- ASYNC,
- WAS_MARKED,
- CONNECTED,
- REACTION_IS_UPDATING
+ ASYNC
} from '#client/constants';
import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
@@ -171,6 +168,17 @@ export function set(source, value, should_proxy = false) {
return internal_set(source, new_value, legacy_updates);
}
+/**
+ * A set of signals we have already seen while traversing in mark_reactions.
+ * Not always set to balance the common case of sources only having a couple
+ * of (transitive) dependencies (where always creating a Set would be bad for perf)
+ * with the edge case of extremely deep or wide dependency arrays with cycles.
+ * @type {Set | null}
+ */
+var seen = null;
+/** Number of transitive dependencies, see {@link seen} for more info */
+var count_deps = 0;
+
/**
* @template V
* @param {Source} source
@@ -240,7 +248,10 @@ export function internal_set(source, value, updated_during_traversal = null) {
// For debugging, in case you want to know which reactions are being scheduled:
// log_reactions(source);
+ seen = null;
+ count_deps = 0;
mark_reactions(source, DIRTY, updated_during_traversal);
+ seen = null;
// It's possible that the current reaction might not have up-to-date dependencies
// whilst it's actively running. So in the case of ensuring it registers the reaction
@@ -347,6 +358,18 @@ function mark_reactions(signal, status, updated_during_traversal) {
var runes = is_runes();
var length = reactions.length;
+ count_deps += length;
+ // Activate the `seen` Set if we think from the unusually high number of deps that
+ // there might be cycles in the graph, to avoid repeated lookups for reactions
+ // Example: https://github.com/sveltejs/svelte/issues/16658 has a graph with one source
+ // reaching ~10000 distinct deriveds/effects each, resulting in 65 million walks through repeated visits.
+ if (count_deps > 100000 && seen === null) seen = new Set();
+
+ if (seen !== null) {
+ if (seen.has(signal)) return;
+ seen.add(signal);
+ }
+
for (var i = 0; i < length; i++) {
var reaction = reactions[i];
var flags = reaction.f;
@@ -370,18 +393,7 @@ function mark_reactions(signal, status, updated_during_traversal) {
var derived = /** @type {Derived} */ (reaction);
batch_values?.delete(derived);
-
- if ((flags & WAS_MARKED) === 0) {
- // Only connected deriveds being executed outside the update cycle can be reliably unmarked right away
- if (
- flags & CONNECTED &&
- (active_effect === null || (active_effect.f & REACTION_IS_UPDATING) === 0)
- ) {
- reaction.f |= WAS_MARKED;
- }
-
- mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
- }
+ mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
} else if (not_dirty) {
var effect = /** @type {Effect} */ (reaction);
diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js
index 0d27cb8b84..3280f2372e 100644
--- a/packages/svelte/src/internal/client/reactivity/utils.js
+++ b/packages/svelte/src/internal/client/reactivity/utils.js
@@ -1,24 +1,7 @@
-/** @import { Derived, Effect, Value } from '#client' */
-import { CLEAN, DERIVED, DIRTY, MAYBE_DIRTY, WAS_MARKED } from '#client/constants';
+/** @import { Effect } from '#client' */
+import { CLEAN, DIRTY, MAYBE_DIRTY } from '#client/constants';
import { set_signal_status } from './status.js';
-/**
- * @param {Value[] | null} deps
- */
-function clear_marked(deps) {
- if (deps === null) return;
-
- for (const dep of deps) {
- if ((dep.f & DERIVED) === 0 || (dep.f & WAS_MARKED) === 0) {
- continue;
- }
-
- dep.f ^= WAS_MARKED;
-
- clear_marked(/** @type {Derived} */ (dep).deps);
- }
-}
-
/**
* @param {Effect} effect
* @param {Set} dirty_effects
@@ -31,10 +14,6 @@ export function defer_effect(effect, dirty_effects, maybe_dirty_effects) {
maybe_dirty_effects.add(effect);
}
- // Since we're not executing these effects now, we need to clear any WAS_MARKED flags
- // so that other batches can correctly reach these effects during their own traversal
- clear_marked(effect.deps);
-
// mark as clean so they get scheduled if they depend on pending async state
set_signal_status(effect, CLEAN);
}
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index d86c349445..27def05300 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -21,7 +21,6 @@ import {
REACTION_IS_UPDATING,
STALE_REACTION,
ERROR_VALUE,
- WAS_MARKED,
MANAGED_EFFECT,
REACTION_RAN
} from './constants.js';
@@ -164,10 +163,6 @@ export function is_dirty(reaction) {
return true;
}
- if (flags & DERIVED) {
- reaction.f &= ~WAS_MARKED;
- }
-
if ((flags & MAYBE_DIRTY) !== 0) {
var dependencies = /** @type {Value[]} */ (reaction.deps);
var length = dependencies.length;
@@ -408,11 +403,8 @@ function remove_reaction(signal, dependency) {
) {
var derived = /** @type {Derived} */ (dependency);
- // If we are working with a derived that is owned by an effect, then mark it as being
- // disconnected and remove the mark flag, as it cannot be reliably removed otherwise
if ((derived.f & CONNECTED) !== 0) {
derived.f ^= CONNECTED;
- derived.f &= ~WAS_MARKED;
}
// In a fork it's possible that a derived is executed and gets reactions, then commits, but is
From ce89035ecbf88ee131838527d29584b968d450fb Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 11:47:41 +0200
Subject: [PATCH 02/29] fix: prevent effect tree of batches from interfering
with each other (#18508)
While working on another Svelte feature I noticed a bug that boils down
to batches interfering with each other through the effect tree: If batch
A schedules an effect, it is walked up to the root (possibly). Now if in
the meantime batch B also wants to schedule effects, it can have
unintended consequences.
Normally this does not happen, since it's extremely hard to run into
this situation. There's basically two cases: Either an unfortunate
timing of microtasks, or during flushing effects are scheduling new
effects which messes with a `#commit()` right after (the test case does
this).
To fix this we now defer walking up the tree until the batch is actually
processed. That way we set + unset the markers on the branches
synchronously so there's no chance of another batch interfering.
---
.changeset/gold-trams-knock.md | 5 +
.../src/internal/client/reactivity/batch.js | 162 ++++++++++--------
.../async-commit-effect-overlap/_config.js | 41 +++++
.../async-commit-effect-overlap/main.svelte | 38 ++++
4 files changed, 175 insertions(+), 71 deletions(-)
create mode 100644 .changeset/gold-trams-knock.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/main.svelte
diff --git a/.changeset/gold-trams-knock.md b/.changeset/gold-trams-knock.md
new file mode 100644
index 0000000000..8006d13fef
--- /dev/null
+++ b/.changeset/gold-trams-knock.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: prevent effect tree of batches from interfering with each other
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 8251f79298..75ce33b1c5 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -22,7 +22,6 @@ import {
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
import {
- active_effect,
active_reaction,
get,
increment_write_version,
@@ -38,7 +37,6 @@ 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';
-import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js';
import { OBSOLETE } from './deriveds.js';
@@ -160,10 +158,17 @@ export class Batch {
#deferred = null;
/**
- * The root effects that need to be flushed
+ * Effects that were scheduled in this batch but not yet 'resolved' into the
+ * root effects that need to be flushed. Resolving — the upwards traversal that
+ * marks the path to each effect on the shared effect tree (see #resolve) — is
+ * deferred until the batch is processed, so that the markers are created and
+ * consumed within a single traversal. Scheduling into other batches (which can
+ * happen concurrently, e.g. while a batch is committed) can therefore never
+ * observe (and be confused by) this batch's markers.
+ * May contain duplicates — deduplication happens during resolving
* @type {Effect[]}
*/
- #roots = [];
+ #scheduled = [];
/**
* Effects created while this batch was active.
@@ -273,14 +278,57 @@ export class Batch {
this.#unskipped_branches.add(effect);
}
- #process() {
- this.#started = true;
+ /**
+ * Convert the effects that were scheduled in this batch into the root effects
+ * that need to be traversed, marking the path to each effect (by clearing the
+ * `CLEAN` flag on ancestor branches) so that the traversal can find them.
+ * This happens right before traversal rather than at scheduling time, so that
+ * the markers left on the (shared) effect tree are created and consumed within
+ * a single traversal — scheduling into other batches can never observe them
+ * @returns {Effect[]}
+ */
+ #resolve() {
+ /** @type {Effect[]} */
+ var roots = [];
- if (flush_count++ > 1000) {
- this.#unlink();
- infinite_loop_guard();
+ for (const effect of this.#scheduled) {
+ // skip effects that are destroyed, or that already ran (e.g. because
+ // they were reached by the traversal that preceded a drain iteration,
+ // or because they were scheduled twice)
+ if ((effect.f & DESTROYED) !== 0 || (effect.f & (DIRTY | MAYBE_DIRTY)) === 0) continue;
+
+ var e = effect;
+ var covered = false;
+
+ while (e.parent !== null) {
+ e = e.parent;
+ var flags = e.f;
+
+ if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
+ if ((flags & CLEAN) === 0) {
+ // the path to the root was already marked, meaning the
+ // root was already collected — nothing left to do
+ covered = true;
+ break;
+ }
+
+ e.f ^= CLEAN;
+ }
+ }
+
+ if (!covered) {
+ roots.push(e);
+ }
}
+ this.#scheduled = [];
+
+ return roots;
+ }
+
+ #process() {
+ this.#started = true;
+
if (DEV) {
// track all the values that were updated during this flush,
// so that they can be reset afterwards
@@ -304,9 +352,6 @@ export class Batch {
this.schedule(e);
}
- const roots = this.#roots;
- this.#roots = [];
-
this.apply();
/** @type {Effect[]} */
@@ -321,18 +366,28 @@ export class Batch {
*/
var updates = (legacy_updates = []);
- for (const root of roots) {
- try {
- this.#traverse(root, effects, render_effects);
- } catch (e) {
- reset_all(root);
- // If there's no async work left, this branch is now dead and needs
- // to be discarded to not become a zombie that is never cleaned up.
- // See https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414
- // for a (non-minimal) reproduction that demonstrates a case where this is necessary
- // to not get follow-up false-positives via "batch has scheduled roots" invariant errors.
- if (!this.#is_deferred()) this.discard();
- throw e;
+ // Effects can be scheduled during traversal (e.g. because a parent each/await/etc
+ // block updated an internal source, or because an effect invalidated itself)
+ // hence we loop until there are no more scheduled effects.
+ while (this.#scheduled.length > 0) {
+ if (flush_count++ > 1000) {
+ this.#unlink();
+ infinite_loop_guard(); // TODO try to reset_all() here?
+ }
+
+ for (const root of this.#resolve()) {
+ try {
+ this.#traverse(root, effects, render_effects);
+ } catch (e) {
+ reset_all(root);
+ // If there's no async work left, this branch is now dead and needs
+ // to be discarded to not become a zombie that is never cleaned up.
+ // See https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414
+ // for a (non-minimal) reproduction that demonstrates a case where this is necessary
+ // to not get follow-up false-positives via "batch has scheduled roots" invariant errors.
+ if (!this.#is_deferred()) this.discard();
+ throw e;
+ }
}
}
@@ -393,7 +448,7 @@ export class Batch {
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
- if (this.#pending === 0 && (this.#roots.length === 0 || next_batch !== null)) {
+ if (this.#pending === 0 && (this.#scheduled.length === 0 || next_batch !== null)) {
this.#unlink();
// Order matters here - we need to commit and THEN continue flushing new batches, not the other way around,
@@ -408,12 +463,15 @@ export class 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
+ // causing an effect 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) {
+ if (this.#scheduled.length > 0) {
if (next_batch !== null) {
- const batch = next_batch;
- batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r)));
+ for (const e of this.#scheduled) {
+ next_batch.#scheduled.push(e);
+ }
+
+ this.#scheduled = [];
} else {
next_batch = this;
}
@@ -712,7 +770,7 @@ export class Batch {
// The microtask queue can contain the batch already scheduled to run right
// after this one is finished, so throwing the invariant would be wrong here.
if (DEV && !batch.#decrement_queued) {
- invariant(batch.#roots.length === 0, 'Batch has scheduled roots');
+ invariant(batch.#scheduled.length === 0, 'Batch has scheduled effects');
}
// A batch was unskipped in a later batch -> tell prior batches to unskip it, too
@@ -768,14 +826,12 @@ export class Batch {
// Only apply and traverse when we know we triggered async work with marking the effects
// and know this won't run anyway right afterwards
- if (batch.#roots.length > 0 && !batch.#decrement_queued) {
+ if (batch.#scheduled.length > 0 && !batch.#decrement_queued) {
batch.apply();
- for (var root of batch.#roots) {
+ for (var root of batch.#resolve()) {
batch.#traverse(root, [], []);
}
-
- batch.#roots = [];
}
batch.deactivate();
@@ -939,43 +995,7 @@ export class Batch {
return;
}
- var e = effect;
-
- while (e.parent !== null) {
- e = e.parent;
- var flags = e.f;
-
- // if the effect is being scheduled because a parent (each/await/etc) block
- // updated an internal source, or because a branch is being unskipped,
- // bail out or we'll cause a second flush
- if (collected_effects !== null && e === active_effect) {
- if (async_mode_flag) return;
-
- // in sync mode, render effects run during traversal. in an extreme edge case
- // — namely that we're setting a value inside a derived read during traversal —
- // they can be made dirty after they have already been visited, in which
- // case we shouldn't bail out. we also shouldn't bail out if we're
- // updating a store inside a `$:`, since this might invalidate
- // effects that were already visited
- if (
- (active_reaction === null || (active_reaction.f & DERIVED) === 0) &&
- !legacy_is_updating_store
- ) {
- return;
- }
- }
-
- if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
- if ((flags & CLEAN) === 0) {
- // branch is already dirty, bail
- return;
- }
-
- e.f ^= CLEAN;
- }
- }
-
- this.#roots.push(e);
+ this.#scheduled.push(effect);
}
#unlink() {
diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js
new file mode 100644
index 0000000000..5557c7ebbb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js
@@ -0,0 +1,41 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons = ' ';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+
+ const [a, b, , pop] = target.querySelectorAll('button');
+ const shift = target.querySelectorAll('button')[2];
+
+ assert.htmlEqual(target.innerHTML, `${buttons}
a
a
aa
1
`);
+
+ // start two independent batches, both blocked on their awaited expression
+ a.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
`);
+
+ // resolve the newer (b) batch first. Committing it must not commit the
+ // still-pending `a` batch, whose async work has not completed — `a` must
+ // still read 'a', and the unrelated `c` update must not be blocked
+ pop.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
a
b
ab
2
`);
+
+ // stale promise from the `a` batch's first run — resolving it does nothing
+ shift.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
From 6be176df2f760e314023e3ddbf42b45fc5575dbb Mon Sep 17 00:00:00 2001
From: Nic Polumeyv
Date: Fri, 11 Sep 2026 07:24:55 -0400
Subject: [PATCH 04/29] fix: resolve the fallback of an each block in the
enclosing scope (#18803)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In `phases/scope.js`, `EachBlock` visits `node.fallback` with the
block's own scope, so `{:else}` resolves the loop's name to the each
context. With `let item = $state('outer')` outside and `{#each items as
item}…{:else}
diff --git a/packages/svelte/tests/hydration/samples/boundary-mismatch-recovery/main.svelte b/packages/svelte/tests/hydration/samples/boundary-mismatch-recovery/main.svelte
new file mode 100644
index 0000000000..8b9cc0770e
--- /dev/null
+++ b/packages/svelte/tests/hydration/samples/boundary-mismatch-recovery/main.svelte
@@ -0,0 +1,7 @@
+
+
{@html '
Valid HTML fragment
'}
+
+ {#snippet failed()}
+
boundary fallback
+ {/snippet}
+
From 38098a931142dfef6ca7330d33264d1908b698fa Mon Sep 17 00:00:00 2001
From: Mariana Castro <121824373+maricastroc@users.noreply.github.com>
Date: Wed, 23 Sep 2026 13:58:28 -0300
Subject: [PATCH 15/29] fix: don't overwrite an unchanged spread `value`
(#18864)
Fixes #18862
When a `value` is applied through a spread (``),
`set_attributes` always writes `element.value`, even if the element
already has that value. Writing `value` on a number input clears any
incomplete text: typing `250.` makes `input.value` `''` (the text is
still visible, but `validity.badInput` is `true`). If the spread `value`
then becomes `''` too, writing `''` erases what the user typed.
This is what happens with SvelteKit remote forms:
`fields.quantity.as('number')` coerces `''` to `undefined`, the spread
`value` becomes `''`, and Svelte writes it back to the input. When `.`
is typed:
- Chromium clears the input in every locale, because `250.` is always
incomplete input there.
- WebKit/Safari clears it in locales where `.` is not the decimal
separator (e.g. `fr-CH`), which is where the reporter saw it.
A plain `` never clears. Neither does the same
`value` in Svelte without a spread (`value={...}`), because `set_value`
already skips writing when `element.value === value`. This PR adds the
same check to the spread path. Svelte 3 had this check too (#3426,
#3495); it was lost in Svelte 5.
The first write still always happens. Writing `value` sets the input's
dirty value flag, and without that flag a `defaultValue` applied
afterwards would change the current value (covered by
`form-default-value-spread`).
This also likely affects sveltejs/kit#16270 (same symptom, plus a cursor
jump caused by redundant writes). That issue is handled on the kit side
in sveltejs/kit#16320; this PR is independent of it.
The test runs in a real browser (`runtime-browser`). It types with
`document.execCommand('insertText')`, which goes through Chromium's own
editing, so `250.` is real incomplete input. The locale is not mocked.
The test fails without this change and passes with it.
### 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: Paolo Ricciuti
---
.changeset/quiet-inputs-keep.md | 5 ++++
.../client/dom/elements/attributes.js | 16 ++++++++++-
.../_config.js | 28 +++++++++++++++++++
.../main.svelte | 5 ++++
4 files changed, 53 insertions(+), 1 deletion(-)
create mode 100644 .changeset/quiet-inputs-keep.md
create mode 100644 packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js
create mode 100644 packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte
diff --git a/.changeset/quiet-inputs-keep.md b/.changeset/quiet-inputs-keep.md
new file mode 100644
index 0000000000..0efcfcc058
--- /dev/null
+++ b/.changeset/quiet-inputs-keep.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't overwrite an unchanged spread `value`, preserving incomplete number input
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 0fe1b8b2d3..7d2dd62baf 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -422,7 +422,21 @@ function set_attributes(
} else if (!is_custom_element && (key === '__value' || (key === 'value' && value != null))) {
// @ts-ignore We're not running this for custom elements because __value is actually
// how Lit stores the current value on the element, and messing with that would break things.
- element.value = element.__value = value;
+ element.__value = value;
+ // we don't set the value if it hasn't changed. This supports invalid number inputs like `1e` because
+ // 1. user types 1e
+ // 2. the state is updated reading e.target.value which is ''
+ // 3. the spreaded value is ''
+ // 4. updating input.value would thus, clear the user value
+ if (
+ prev_value == null ||
+ // @ts-ignore
+ element.value !== value ||
+ (value === 0 && element.nodeName === PROGRESS_TAG)
+ ) {
+ // @ts-ignore
+ element.value = value;
+ }
} else if (key === 'selected' && is_option_element) {
set_selected(/** @type {HTMLOptionElement} */ (element), value);
} else {
diff --git a/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js
new file mode 100644
index 0000000000..cec1e179ac
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js
@@ -0,0 +1,28 @@
+import { flushSync } from 'svelte';
+import { ok, test } from '../../assert';
+
+export default test({
+ async test({ assert, target }) {
+ const input = target.querySelector('input');
+ ok(input);
+
+ input.focus();
+
+ // we need to use `document.execCommand('insertText', false, ...)` to simulate user input
+ // because directly setting an invalid value to `input.value` would simply clear the input
+ // and dispatching an event would not update the input correctly
+ document.execCommand('insertText', false, '1');
+ flushSync();
+ // `1e` is incomplete on every platform, unlike `1.` which Chromium on Linux accepts as `1`
+ document.execCommand('insertText', false, 'e');
+ flushSync();
+
+ assert.equal(input.value, '');
+ assert.equal(input.validity.badInput, true);
+
+ document.execCommand('insertText', false, '5');
+ flushSync();
+
+ assert.equal(input.value, '1e5');
+ }
+});
diff --git a/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte
new file mode 100644
index 0000000000..3e7a5cc335
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte
@@ -0,0 +1,5 @@
+
+
+ (value = e.currentTarget.value)} />
From 58297c5a73e6a06687c3892efbfc807eec3f23af Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 24 Sep 2026 04:19:37 -0400
Subject: [PATCH 16/29] chore: extract some minor changes from #18861 (#18866)
doing my usual thing of extracting no-brainer changes from a giant PR to
try and make it slightly less giant
---
packages/svelte/src/internal/client/dev/tracing.js | 4 +++-
packages/svelte/src/internal/client/dom/blocks/each.js | 4 ++--
packages/svelte/src/internal/client/reactivity/batch.js | 4 ++++
packages/svelte/src/internal/client/reactivity/sources.js | 6 +++---
packages/svelte/src/internal/client/runtime.js | 6 +++---
packages/svelte/src/reactivity/date.js | 4 ++--
6 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js
index c6edfde933..9fea8175bd 100644
--- a/packages/svelte/src/internal/client/dev/tracing.js
+++ b/packages/svelte/src/internal/client/dev/tracing.js
@@ -131,8 +131,10 @@ export function trace(label, fn) {
}
/**
- * @param {Value} source
+ * @template {Value} T
+ * @param {T} source
* @param {string} label
+ * @returns {T}
*/
export function tag(source, label) {
source.label = label;
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 9a2504f887..ea828f893b 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -1,4 +1,4 @@
-/** @import { EachItem, EachOutroGroup, EachState, Effect, EffectNodes, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */
+/** @import { EachItem, EachOutroGroup, EachState, Effect, EffectNodes, MaybeSource, TemplateNode, TransitionManager } from '#client' */
/** @import { Batch } from '../../reactivity/batch.js'; */
import {
EACH_INDEX_REACTIVE,
@@ -664,7 +664,7 @@ function reconcile(state, array, anchor, flags, get_key) {
* @param {V} value
* @param {unknown} key
* @param {number} index
- * @param {(anchor: Node, item: V | Source, index: number | Value, collection: () => V[]) => void} render_fn
+ * @param {(anchor: Node, item: MaybeSource, index: MaybeSource, collection: () => V[]) => void} render_fn
* @param {number} flags
* @param {() => V[]} get_collection
* @returns {EachItem}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 75ce33b1c5..f620c2b4c8 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -1295,6 +1295,10 @@ export function eager(fn) {
let version = version_map.get(parent) ?? source(0);
version_map.set(parent, version);
+ if (DEV) {
+ version.label ??= '$state.eager version';
+ }
+
teardown(() => {
if (parent.f & DESTROYING) version_map.delete(parent);
});
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 006494f0b5..4b10ddbd31 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -46,7 +46,7 @@ import { set_signal_status, update_derived_status } from './status.js';
/** @type {Set} */
export let eager_effects = new Set();
-/** @type {Map} */
+/** @type {Map} */
export const old_values = new Map();
/**
@@ -141,7 +141,7 @@ export function mutate(source, value) {
/**
* @template V
- * @param {Source} source
+ * @param {Value} source
* @param {V} value
* @param {boolean} [should_proxy]
* @returns {V}
@@ -181,7 +181,7 @@ var count_deps = 0;
/**
* @template V
- * @param {Source} source
+ * @param {Value} source
* @param {V} value
* @param {Effect[] | null} [updated_during_traversal]
* @returns {V}
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 27def05300..6212605008 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -93,7 +93,7 @@ export function set_active_effect(effect) {
/**
* When sources are created within a reaction, reading and writing
* them within that reaction should not cause a re-run
- * @type {null | Set}
+ * @type {null | Set}
*/
export let current_sources = null;
@@ -121,11 +121,11 @@ export let skipped_deps = 0;
/**
* Tracks writes that the effect it's executed in doesn't listen to yet,
* so that the dependency can be added to the effect later on if it then reads it
- * @type {null | Source[]}
+ * @type {null | Value[]}
*/
export let untracked_writes = null;
-/** @param {null | Source[]} value */
+/** @param {null | Value[]} value */
export function set_untracked_writes(value) {
untracked_writes = value;
}
diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js
index f882c05d76..b70ba7969a 100644
--- a/packages/svelte/src/reactivity/date.js
+++ b/packages/svelte/src/reactivity/date.js
@@ -1,4 +1,4 @@
-/** @import { Source } from '#client' */
+/** @import { Derived } from '#client' */
import { derived } from '../internal/client/index.js';
import { set, state } from '../internal/client/reactivity/sources.js';
import { tag } from '../internal/client/dev/tracing.js';
@@ -42,7 +42,7 @@ var inited = false;
export class SvelteDate extends Date {
#time = state(super.getTime());
- /** @type {Map>} */
+ /** @type {Map>} */
#deriveds = new Map();
#reaction = active_reaction;
From da2f83343bf5e2fb183881b74ef06fac24f0e249 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Thu, 24 Sep 2026 22:47:52 +0200
Subject: [PATCH 17/29] fix: read batch-local array on each-block commit
(#18879)
Another extraction from #18861 which I'm pretty sure you can also
construct a buggy reproduction for today, but with #18861 it's strictly
necessary so we don't need to come up with one now
---
.changeset/short-radios-sneeze.md | 5 +++++
packages/svelte/src/internal/client/dom/blocks/each.js | 10 ++++++----
2 files changed, 11 insertions(+), 4 deletions(-)
create mode 100644 .changeset/short-radios-sneeze.md
diff --git a/.changeset/short-radios-sneeze.md b/.changeset/short-radios-sneeze.md
new file mode 100644
index 0000000000..847c3eef17
--- /dev/null
+++ b/.changeset/short-radios-sneeze.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: read batch-local array on each-block commit
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index ea828f893b..58d59a6825 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -216,9 +216,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
tag(each_array, '{#each ...}');
}
- /** @type {V[]} */
- var array;
-
/** @type {Map>} */
var pending = new Map();
@@ -234,6 +231,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
state.pending.delete(batch);
+ // The effect doesn't necessarily re-run in a batch right before that batch commits
+ // (its view of the collection may not have changed), so we read the collection
+ // as the committing batch sees it rather than using the most recent block run's result
+ var array = get(each_array);
+
state.fallback = fallback;
reconcile(state, array, anchor, flags, get_key);
@@ -264,7 +266,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
var effect = block(() => {
- array = /** @type {V[]} */ (get(each_array));
+ var array = /** @type {V[]} */ (get(each_array));
var length = array.length;
/** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
From 98e547535d9286cfd4163fe3a6d85c7053db6c6e Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Thu, 24 Sep 2026 22:49:15 +0200
Subject: [PATCH 18/29] fix: flush anything pending before invoking flushSync
callback function (#18878)
no new test here but it's necessary for #18861 to work, and we might as
well extract it
---
.changeset/major-papayas-run.md | 5 +++++
.../svelte/src/internal/client/reactivity/batch.js | 12 ++++++++----
2 files changed, 13 insertions(+), 4 deletions(-)
create mode 100644 .changeset/major-papayas-run.md
diff --git a/.changeset/major-papayas-run.md b/.changeset/major-papayas-run.md
new file mode 100644
index 0000000000..59909fb448
--- /dev/null
+++ b/.changeset/major-papayas-run.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: flush anything pending before invoking flushSync callback function
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index f620c2b4c8..05f7586072 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -1032,15 +1032,15 @@ export class Batch {
*/
export function flushSync(fn) {
var was_flushing_sync = is_flushing_sync;
+ var prev_previous_batch = previous_batch;
+ previous_batch = null;
is_flushing_sync = true;
try {
var result;
if (fn) {
- if (current_batch !== null && !current_batch.is_fork) {
- current_batch.flush();
- }
+ flushSync(); // flush anything pending through the while loop below
result = fn();
}
@@ -1056,6 +1056,7 @@ export function flushSync(fn) {
}
} finally {
is_flushing_sync = was_flushing_sync;
+ previous_batch = prev_previous_batch;
}
}
@@ -1406,6 +1407,8 @@ export function fork(fn) {
e.fork_timing();
}
+ flushSync();
+
var batch = Batch.ensure();
batch.is_fork = true;
batch_values = new Map();
@@ -1413,7 +1416,8 @@ export function fork(fn) {
var committed = false;
var settled = batch.settled();
- flushSync(fn);
+ fn();
+ flushSync();
return {
commit: async () => {
From f7ca0a44a5d198d600521afa44341736c9bee0c8 Mon Sep 17 00:00:00 2001
From: Dex
Date: Fri, 25 Sep 2026 00:04:17 +0200
Subject: [PATCH 19/29] chore: fix typos in code comments (#18860)
Fixes four typos in code comments (no behavioral change):
- `packages/svelte/src/index-client.js`: `noone` -> `no one`
- `packages/svelte/src/internal/client/dom/css.js`: `Therfore` ->
`Therefore`
- `packages/svelte/src/internal/client/runtime.js`: `loosing` ->
`losing`
- `packages/svelte/src/internal/server/index.js`: `aswell` -> `as well`
Checklist: title prefixed with `chore:`; comment-only change so no
changeset and no behavior affected.
Co-authored-by: Dextheking1
---
packages/svelte/src/index-client.js | 2 +-
packages/svelte/src/internal/client/dom/css.js | 2 +-
packages/svelte/src/internal/client/runtime.js | 2 +-
packages/svelte/src/internal/server/index.js | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js
index 0eb1b80315..3da59eb7f7 100644
--- a/packages/svelte/src/index-client.js
+++ b/packages/svelte/src/index-client.js
@@ -17,7 +17,7 @@ if (DEV) {
if (!(rune in globalThis)) {
// TODO if people start adjusting the "this can contain runes" config through v-p-s more, adjust this message
/** @type {any} */
- let value; // let's hope noone modifies this global, but belts and braces
+ let value; // let's hope no one modifies this global, but belts and braces
Object.defineProperty(globalThis, rune, {
configurable: true,
// eslint-disable-next-line getter-return
diff --git a/packages/svelte/src/internal/client/dom/css.js b/packages/svelte/src/internal/client/dom/css.js
index 23f4eff4f7..78a32901fe 100644
--- a/packages/svelte/src/internal/client/dom/css.js
+++ b/packages/svelte/src/internal/client/dom/css.js
@@ -12,7 +12,7 @@ export function append_styles(anchor, css) {
// Use an effect to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
effect(() => {
// Bit of a hack: branches.js/each.js use offscreen fragments with temporary text nodes that will
- // never be connected to the real dom. Therfore walk up to the branch that has created the component
+ // never be connected to the real dom. Therefore walk up to the branch that has created the component
// whose styles we want to append, and check its node instead. It will be connected by the time we get here.
anchor = active_effect?.parent?.nodes?.start ?? anchor;
var root = anchor.getRootNode();
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 6212605008..76802974b7 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -410,7 +410,7 @@ function remove_reaction(signal, dependency) {
// In a fork it's possible that a derived is executed and gets reactions, then commits, but is
// never re-executed. This is possible when the derived is only executed once in the context
// of a new branch which happens before fork.commit() runs. In this case, the derived still has
- // UNINITIALIZED as its value, and then when it's loosing its reactions we need to ensure it stays
+ // UNINITIALIZED as its value, and then when it's losing its reactions we need to ensure it stays
// DIRTY so it is reexecuted once someone wants its value again.
if (derived.v !== UNINITIALIZED) {
update_derived_status(derived);
diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js
index 20bef78306..6a63bd9fd6 100644
--- a/packages/svelte/src/internal/server/index.js
+++ b/packages/svelte/src/internal/server/index.js
@@ -168,7 +168,7 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) {
// value/checked takes precedence over defaultValue/defaultChecked
name = name === 'defaultvalue' ? 'value' : 'checked';
if (name in attrs) continue;
- // We're checking prior entries aswell because "name in attrs" is not enough as the attributes may have different casing
+ // We're checking prior entries as well because "name in attrs" is not enough as the attributes may have different casing
for (let j = 0; j < names.length; j++) {
if (names[j].toLowerCase() === name) continue outer;
}
From 75aa6ba38d58c83ff012bed77c39edb814e43bc6 Mon Sep 17 00:00:00 2001
From: G
Date: Fri, 25 Sep 2026 04:44:00 +0200
Subject: [PATCH 20/29] fix: don't warn about redundant `link` role on ``
without `href` (#18872)
Since #11737, `` without an `href` no longer triggers
`a11y_no_redundant_roles`, but `` without an `href`
still does. Per [ARIA in
HTML](https://www.w3.org/TR/html-aria/#el-area-no-href), an ``
without `href` has the implicit role `generic` and `link` is one of its
allowed roles, so the role isn't redundant. This applies the existing
`` exception to `` and adds test cases for `` with and
without `href`.
Closes #9261. The `` example in that issue was already fixed by
#11737, but the issue was left open.
### Before submitting the PR, please make sure you do the following
- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).
### Tests and linting
- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
---
.changeset/quiet-areas-link.md | 5 +++++
.../phases/2-analyze/visitors/shared/a11y/index.js | 4 ++--
.../samples/a11y-no-redundant-roles/input.svelte | 5 +++++
.../samples/a11y-no-redundant-roles/warnings.json | 12 ++++++++++++
4 files changed, 24 insertions(+), 2 deletions(-)
create mode 100644 .changeset/quiet-areas-link.md
diff --git a/.changeset/quiet-areas-link.md b/.changeset/quiet-areas-link.md
new file mode 100644
index 0000000000..af4e31f3cb
--- /dev/null
+++ b/.changeset/quiet-areas-link.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't warn about a redundant `link` role on `` elements without an `href`
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js
index f06a41d088..c38b03bf69 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js
@@ -173,8 +173,8 @@ export function check_element(node, context) {
current_role === get_implicit_role(node.name, attribute_map) &&
//
is ok because CSS list-style:none removes the semantics and this is a way to bring them back
!['ul', 'ol', 'li', 'menu'].includes(node.name) &&
- // is ok because without href the a tag doesn't have a role of link
- !(node.name === 'a' && !attribute_map.has('href'))
+ // and are ok because without href they don't have a role of link
+ !((node.name === 'a' || node.name === 'area') && !attribute_map.has('href'))
) {
w.a11y_no_redundant_roles(attribute, current_role);
}
diff --git a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte
index 9e06ebe420..68965fb5f4 100644
--- a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte
+++ b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte
@@ -43,6 +43,11 @@
+
+
+
+
+
diff --git a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json
index e041037d71..8e94fef00a 100644
--- a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json
+++ b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json
@@ -454,5 +454,17 @@
"column": 8,
"line": 44
}
+ },
+ {
+ "code": "a11y_no_redundant_roles",
+ "end": {
+ "column": 26,
+ "line": 47
+ },
+ "message": "Redundant role 'link'",
+ "start": {
+ "column": 15,
+ "line": 47
+ }
}
]
From a956c2bd1fe84eff8e01662b948234c9193c6253 Mon Sep 17 00:00:00 2001
From: waltu
Date: Fri, 25 Sep 2026 04:44:12 +0200
Subject: [PATCH 21/29] fix: error when using `let:` directives on a component
with a `children` snippet (#18873)
Closes #13864
A `let:` directive on a component only applies to the default slot
content. If the content is an explicit `{#snippet children()}` block,
the value is never provided, so code like this compiles but fails at
runtime with "value is not defined":
```svelte
{#snippet children()}Value is: {value}{/snippet}
```
This adds a compile error on the directive that points to snippet
parameters instead (`{#snippet children({ value })}`, or `{ item: x }`
for `let:item={x}`). Components with a `slot` attribute are not
affected, since there the directive applies to the component itself. An
unused `let:` next to a `children` snippet is also flagged now, but it
never had any effect.
### 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: waltu <182410443+waltuov@users.noreply.github.com>
---
.changeset/sour-lets-hide.md | 5 ++++
.../98-reference/.generated/compile-errors.md | 6 +++++
.../messages/compile-errors/template.md | 4 ++++
packages/svelte/src/compiler/errors.js | 10 ++++++++
.../phases/2-analyze/visitors/LetDirective.js | 22 ++++++++++++++++++
.../_config.js | 10 ++++++++
.../main.svelte | 7 ++++++
.../let-directive-children-snippet/_config.js | 10 ++++++++
.../main.svelte | 23 +++++++++++++++++++
9 files changed, 97 insertions(+)
create mode 100644 .changeset/sour-lets-hide.md
create mode 100644 packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/_config.js
create mode 100644 packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/main.svelte
create mode 100644 packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/_config.js
create mode 100644 packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/main.svelte
diff --git a/.changeset/sour-lets-hide.md b/.changeset/sour-lets-hide.md
new file mode 100644
index 0000000000..6175027cff
--- /dev/null
+++ b/.changeset/sour-lets-hide.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: error when using `let:` directives on a component with a `children` snippet
diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md
index 02e762dc48..eea1be14c8 100644
--- a/documentation/docs/98-reference/.generated/compile-errors.md
+++ b/documentation/docs/98-reference/.generated/compile-errors.md
@@ -677,6 +677,12 @@ Cannot use `$$restProps` in runes mode
`let:` directive at invalid position
```
+### let_directive_snippet_conflict
+
+```
+Cannot use `let:` directives on a component that has a `children` snippet. Use snippet parameters instead (e.g. `{#snippet children({ %name% })}`)
+```
+
### mixed_event_handler_syntaxes
```
diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md
index 6d1ae6ccbf..f71119169f 100644
--- a/packages/svelte/messages/compile-errors/template.md
+++ b/packages/svelte/messages/compile-errors/template.md
@@ -279,6 +279,10 @@ The same applies to components:
> `let:` directive at invalid position
+## let_directive_snippet_conflict
+
+> Cannot use `let:` directives on a component that has a `children` snippet. Use snippet parameters instead (e.g. `{#snippet children({ %name% })}`)
+
## mixed_event_handler_syntaxes
> Mixing old (on:%name%) and new syntaxes for event handling is not allowed. Use only the on%name% syntax
diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js
index ccb7fe833e..f2be746ff6 100644
--- a/packages/svelte/src/compiler/errors.js
+++ b/packages/svelte/src/compiler/errors.js
@@ -1213,6 +1213,16 @@ export function let_directive_invalid_placement(node) {
e(node, 'let_directive_invalid_placement', `\`let:\` directive at invalid position\nhttps://svelte.dev/e/let_directive_invalid_placement`);
}
+/**
+ * Cannot use `let:` directives on a component that has a `children` snippet. Use snippet parameters instead (e.g. `{#snippet children({ %name% })}`)
+ * @param {null | number | NodeLike} node
+ * @param {string} name
+ * @returns {never}
+ */
+export function let_directive_snippet_conflict(node, name) {
+ e(node, 'let_directive_snippet_conflict', `Cannot use \`let:\` directives on a component that has a \`children\` snippet. Use snippet parameters instead (e.g. \`{#snippet children({ ${name} })}\`)\nhttps://svelte.dev/e/let_directive_snippet_conflict`);
+}
+
/**
* Mixing old (on:%name%) and new syntaxes for event handling is not allowed. Use only the on%name% syntax
* @param {null | number | NodeLike} node
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/LetDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/LetDirective.js
index e9a56ead0c..2eccac9d8a 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/LetDirective.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/LetDirective.js
@@ -1,6 +1,7 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
+import { determine_slot } from '../../../utils/slot.js';
/**
* @param {AST.LetDirective} node
@@ -21,4 +22,25 @@ export function LetDirective(node, context) {
) {
e.let_directive_invalid_placement(node);
}
+
+ // an explicit `children` snippet replaces the default slot content, so the `let:`
+ // directive would never be provided. if the component has a `slot` attribute, the
+ // directive applies to the component itself instead, which is fine
+ if (
+ (parent.type === 'Component' ||
+ parent.type === 'SvelteComponent' ||
+ parent.type === 'SvelteSelf') &&
+ !determine_slot(parent) &&
+ parent.fragment.nodes.some(
+ (child) => child.type === 'SnippetBlock' && child.expression.name === 'children'
+ )
+ ) {
+ const { expression } = node;
+ const pattern =
+ expression === null || (expression.type === 'Identifier' && expression.name === node.name)
+ ? node.name
+ : `${node.name}: ${context.state.analysis.source.slice(expression.start, expression.end)}`;
+
+ e.let_directive_snippet_conflict(node, pattern);
+ }
}
diff --git a/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/_config.js b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/_config.js
new file mode 100644
index 0000000000..bf56000196
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/_config.js
@@ -0,0 +1,10 @@
+import { test } from '../../test';
+
+export default test({
+ error: {
+ code: 'let_directive_snippet_conflict',
+ message:
+ 'Cannot use `let:` directives on a component that has a `children` snippet. Use snippet parameters instead (e.g. `{#snippet children({ item: x })}`)',
+ position: [62, 74]
+ }
+});
diff --git a/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/main.svelte b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/main.svelte
new file mode 100644
index 0000000000..eddd5f2308
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet-expression/main.svelte
@@ -0,0 +1,7 @@
+
+
+
+ {#snippet children()}{x}{/snippet}
+
diff --git a/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/_config.js b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/_config.js
new file mode 100644
index 0000000000..27b6a88667
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/_config.js
@@ -0,0 +1,10 @@
+import { test } from '../../test';
+
+export default test({
+ error: {
+ code: 'let_directive_snippet_conflict',
+ message:
+ 'Cannot use `let:` directives on a component that has a `children` snippet. Use snippet parameters instead (e.g. `{#snippet children({ value })}`)',
+ position: [359, 368]
+ }
+});
diff --git a/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/main.svelte b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/main.svelte
new file mode 100644
index 0000000000..bdfe3de9b5
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/let-directive-children-snippet/main.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+ {value}
+ {#snippet foo()}x{/snippet}
+
+
+ {#snippet children({ value })}{value}{/snippet}
+
+
+
+ {#snippet children()}{value}{/snippet}
+
+
+
+
+
+ {#snippet children()}Value is: {value}{/snippet}
+
From f90853565966bc0bab57c99fe7d669a91bef0f4c Mon Sep 17 00:00:00 2001
From: "svelte-triage-bot[bot]"
<316883489+svelte-triage-bot[bot]@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:41:51 +0200
Subject: [PATCH 22/29] fix: prevent untracked derived reads from retaining
dependencies (#18829)
Fixes https://github.com/sveltejs/svelte/issues/18827.
A disconnected derived re-evaluated through `untrack` inside an effect
could connect descendant deriveds because `is_updating_effect` was true,
even though the active derived reader was not connected. Those
descendants registered on long-lived dependencies without gaining a
reaction that could later trigger their disconnect cascade.
This changes `get()` to connect a derived only when its active reader is
itself `CONNECTED`. Effects already carry that flag, and deriveds
reached through connected readers receive it before evaluation, so
tracked behavior remains intact. The now-unused `is_updating_effect`
state is removed.
---
.changeset/calm-derived-connections.md | 5 ++
.../svelte/src/internal/client/runtime.js | 13 +---
packages/svelte/tests/signals/test.ts | 60 ++++++++++++++++++-
3 files changed, 65 insertions(+), 13 deletions(-)
create mode 100644 .changeset/calm-derived-connections.md
diff --git a/.changeset/calm-derived-connections.md b/.changeset/calm-derived-connections.md
new file mode 100644
index 0000000000..4ce38365ff
--- /dev/null
+++ b/.changeset/calm-derived-connections.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: prevent untracked derived reads from retaining disconnected dependencies
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 76802974b7..31d6ebc9ae 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -60,11 +60,6 @@ import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js';
import * as w from './warnings.js';
-/**
- * True if updating in an effect context that is reactive (i.e. not branch/root effects)
- */
-let is_updating_effect = false;
-
export let is_destroying_effect = false;
/** @param {boolean} value */
@@ -462,10 +457,8 @@ export function update_effect(effect) {
set_signal_status(effect, CLEAN);
var previous_effect = active_effect;
- var was_updating_effect = is_updating_effect;
active_effect = effect;
- is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts
if (DEV) {
var previous_component_fn = dev_current_component_function;
@@ -498,7 +491,6 @@ export function update_effect(effect) {
}
}
} finally {
- is_updating_effect = was_updating_effect;
active_effect = previous_effect;
if (DEV) {
@@ -680,13 +672,12 @@ export function get(signal) {
return value;
}
- // connect disconnected deriveds if we are reading them inside an effect,
- // or inside another derived that is already connected
+ // connect disconnected deriveds when reading them inside a connected reaction
var should_connect =
(derived.f & CONNECTED) === 0 &&
!untracking &&
active_reaction !== null &&
- (is_updating_effect || (active_reaction.f & CONNECTED) !== 0);
+ (active_reaction.f & CONNECTED) !== 0;
var is_new = (derived.f & REACTION_RAN) === 0;
diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts
index 285dc1b3ce..afc866951f 100644
--- a/packages/svelte/tests/signals/test.ts
+++ b/packages/svelte/tests/signals/test.ts
@@ -15,7 +15,7 @@ import { proxy } from '../../src/internal/client/proxy';
import { derived } from '../../src/internal/client/reactivity/deriveds';
import { snapshot } from '../../src/internal/shared/clone.js';
import { SvelteSet } from '../../src/reactivity/set';
-import { DESTROYED } from '../../src/internal/client/constants';
+import { CONNECTED, DESTROYED } from '../../src/internal/client/constants';
import { noop } from 'svelte/internal/client';
import { disable_async_mode_flag, enable_async_mode_flag } from '../../src/internal/flags';
@@ -1516,12 +1516,68 @@ describe('signals', () => {
destroy();
- // a was spuriously added to s.reactions via is_updating_effect
+ // a was spuriously added to s.reactions
// even though the entire derived chain was read in an untracked context
assert.equal(s.reactions, null);
};
});
+ test('untracked derived reads inside effects do not reconnect disconnected dependencies', () => {
+ return () => {
+ const source = state({ n: 1, items: [1] });
+ const data = derived(() => $.get(source));
+ const items = derived(() => $.get(data).items);
+ const count = derived(() => Math.max(1, $.get(items).length));
+ const snapshot = derived(() => ({ n: $.get(data).n, count: $.get(count) }));
+ const show = state(true);
+ const trigger = state(0);
+ let rendered = -1;
+ let seen: { n: number; count: number } | undefined;
+
+ const destroy = effect_root(() => {
+ render_effect(() => {
+ if ($.get(show)) {
+ render_effect(() => {
+ rendered = $.get(snapshot).count;
+ });
+ }
+ });
+
+ render_effect(() => {
+ $.get(trigger);
+ seen = $.untrack(() => $.get(snapshot));
+ });
+ });
+
+ flushSync();
+ assert.equal(rendered, 1);
+
+ flushSync(() => set(show, false));
+ assert.equal(source.reactions, null);
+
+ flushSync(() => set(source, { n: 2, items: [1, 2] }));
+ flushSync(() => set(trigger, 1));
+
+ assert.deepEqual(seen, { n: 2, count: 2 });
+ assert.equal(source.reactions, null);
+ assert.equal(items.reactions, null);
+ assert.equal(count.reactions, null);
+ assert.equal(items.f & CONNECTED, 0);
+ assert.equal(count.f & CONNECTED, 0);
+
+ flushSync(() => set(show, true));
+ assert.equal(rendered, 2);
+ assert.equal(source.reactions?.length, 1);
+
+ flushSync(() => set(source, { n: 3, items: [1] }));
+ assert.equal(rendered, 1);
+
+ destroy();
+ flushSync();
+ assert.equal(source.reactions, null);
+ };
+ });
+
// https://github.com/sveltejs/svelte/issues/18414
test('a reaction that throws after first-reading a fresh derived does not leak it', () => {
const src = state(0);
From 7fb397e78f3c07908cb88ef894658d7433eb3938 Mon Sep 17 00:00:00 2001
From: "svelte-triage-bot[bot]"
<316883489+svelte-triage-bot[bot]@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:32:59 +0200
Subject: [PATCH 23/29] fix: handle deferred transitions aborted before
initialization (#18776)
- make deferred transition facade methods safe before the inner
animation initializes
- report the transition's initial position from the facade until
initialization
- add a runtime regression test for an out-only `crossfade` branch
restored in the same update
Fixes https://github.com/sveltejs/svelte/issues/18773
---------
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
---
.changeset/tidy-ducks-reset.md | 5 ++++
.../client/dom/elements/transitions.js | 6 ++---
.../deferred-outro-restored/_config.js | 13 ++++++++++
.../deferred-outro-restored/main.svelte | 24 +++++++++++++++++++
4 files changed, 45 insertions(+), 3 deletions(-)
create mode 100644 .changeset/tidy-ducks-reset.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/deferred-outro-restored/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/deferred-outro-restored/main.svelte
diff --git a/.changeset/tidy-ducks-reset.md b/.changeset/tidy-ducks-reset.md
new file mode 100644
index 0000000000..41a00cf649
--- /dev/null
+++ b/.changeset/tidy-ducks-reset.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: handle deferred transitions aborted before initialization
diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js
index 67b177841a..f6d36d86de 100644
--- a/packages/svelte/src/internal/client/dom/elements/transitions.js
+++ b/packages/svelte/src/internal/client/dom/elements/transitions.js
@@ -359,9 +359,9 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
aborted = true;
a?.abort();
},
- deactivate: () => a.deactivate(),
- reset: () => a.reset(),
- t: () => a.t()
+ deactivate: () => a?.deactivate(),
+ reset: () => a?.reset(),
+ t: () => a?.t() ?? 1 - t2
};
}
diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-outro-restored/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-outro-restored/_config.js
new file mode 100644
index 0000000000..367da37815
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/deferred-outro-restored/_config.js
@@ -0,0 +1,13 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [button] = target.querySelectorAll('button');
+
+ button.click();
+ flushSync();
+
+ assert.htmlEqual(target.innerHTML, 'Reproduce
\ No newline at end of file
From 28b99aa8e66e50c80d1b7fd77502333a7e02bc72 Mon Sep 17 00:00:00 2001
From: Nic Polumeyv
Date: Mon, 28 Sep 2026 17:05:21 -0400
Subject: [PATCH 28/29] fix: error at compile time when a declaration in a
snippet redeclares one of its parameters (#18842)
`{const item = 1}` inside `{#snippet row(item)}` compiles, and the
output declares `item` twice in one function, so it does not parse:
```js
const row = ($$anchor, item = $.noop) => {
const item = 1;
```
`{let}`, `{@const}` and a nested snippet with the parameter's name do
the same. In `phases/scope.js`, `EachBlock` visits its body's children
in the item's own scope, so a tag that reuses the item name is a
`declaration_duplicate`. `SnippetBlock` calls `context.next({ scope:
child_scope })`, the `Fragment` visitor opens another scope for the
body, and the tag never meets the parameter.
After visiting the body, `SnippetBlock` now reports any name in the
body's scope that `child_scope` also declares. A declaration in a nested
block or element still shadows the parameter.
---------
Co-authored-by: Simon Holthausen
---
.changeset/snippet-parameter-redeclared.md | 5 +++++
packages/svelte/src/compiler/phases/scope.js | 8 ++++++++
.../errors.json | 14 ++++++++++++++
.../input.svelte | 17 +++++++++++++++++
4 files changed, 44 insertions(+)
create mode 100644 .changeset/snippet-parameter-redeclared.md
create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/errors.json
create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/input.svelte
diff --git a/.changeset/snippet-parameter-redeclared.md b/.changeset/snippet-parameter-redeclared.md
new file mode 100644
index 0000000000..9bb14cf3af
--- /dev/null
+++ b/.changeset/snippet-parameter-redeclared.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: error at compile time when a declaration in a snippet redeclares one of its parameters
diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js
index 77c9119364..0d8e570772 100644
--- a/packages/svelte/src/compiler/phases/scope.js
+++ b/packages/svelte/src/compiler/phases/scope.js
@@ -1343,6 +1343,14 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
}
context.next({ scope: child_scope });
+
+ // the parameters and what the body declares end up in the same function
+ const body_scope = /** @type {Scope} */ (scopes.get(node.body));
+ for (const [name, binding] of body_scope.declarations) {
+ if (child_scope.declarations.has(name)) {
+ e.declaration_duplicate(binding.node, name);
+ }
+ }
},
Fragment: (node, context) => {
diff --git a/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/errors.json
new file mode 100644
index 0000000000..45e51d5f01
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "declaration_duplicate",
+ "message": "`item` has already been declared",
+ "start": {
+ "line": 13,
+ "column": 8
+ },
+ "end": {
+ "line": 13,
+ "column": 12
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/input.svelte
new file mode 100644
index 0000000000..b59496dda1
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-snippet-parameter/input.svelte
@@ -0,0 +1,17 @@
+
+{#snippet row1(item)}
+{#if item}
+{const item = 1}
+
+{/snippet}
+
+{@render row2(5)}
From 020242d6bef059df9ae8c13dc8dbff4c9b31e0ff Mon Sep 17 00:00:00 2001
From: Minh Vu
Date: Mon, 28 Sep 2026 23:31:46 +0200
Subject: [PATCH 29/29] fix: ensure hydration state restoration for custom
element attribute updates (#18467)
### Problem
While hydrating custom elements, set_attributes temporarily disables
global hydration state via set_hydrating(false) and restores it only at
the end. If a prop setter or attribute operation throws partway through,
hydration mode can remain disabled globally.
### Fix
Wrap the temporary hydration-state override in a try/finally block in
set_attributes so set_hydrating(true) always runs when
is_hydrating_custom_element is true.
---------
Co-authored-by: Simon Holthausen
---
...hydration-custom-element-error-boundary.md | 5 +
.../client/dom/elements/attributes.js | 330 +++++++++---------
.../_config.js | 11 +
.../main.svelte | 22 ++
4 files changed, 206 insertions(+), 162 deletions(-)
create mode 100644 .changeset/hydration-custom-element-error-boundary.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/hydration-custom-element-error-boundary/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/hydration-custom-element-error-boundary/main.svelte
diff --git a/.changeset/hydration-custom-element-error-boundary.md b/.changeset/hydration-custom-element-error-boundary.md
new file mode 100644
index 0000000000..f94aa6265a
--- /dev/null
+++ b/.changeset/hydration-custom-element-error-boundary.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: restore hydration state when custom element attribute updates throw
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 7d2dd62baf..982a405f98 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -298,196 +298,202 @@ function set_attributes(
var is_option_element = element.nodeName === OPTION_TAG;
var is_select_element = element.nodeName === SELECT_TAG;
- for (var key in prev) {
- // don't null our internal $$onX listeners
- if (!(key in next) && key[0] + key[1] !== '$$') {
- next[key] = null;
- }
- }
-
- if (next.class) {
- next.class = clsx(next.class);
- } else if (css_hash || next[CLASS]) {
- next.class = null; /* force call to set_class() */
- }
-
- if (next[STYLE]) {
- next.style ??= null; /* force call to set_style() */
- }
-
- var setters = get_setters(element);
-
- if (element.nodeName === INPUT_TAG && 'type' in next && ('value' in next || '__value' in next)) {
- var type = next.type;
-
- if (type !== current.type || (type === undefined && element.hasAttribute('type'))) {
- current.type = type;
- set_attribute(element, 'type', type, skip_warning);
+ try {
+ for (var key in prev) {
+ // don't null our internal $$onX listeners
+ if (!(key in next) && key[0] + key[1] !== '$$') {
+ next[key] = null;
+ }
}
- }
- // since key is captured we use const
- for (const key in next) {
- // let instead of var because referenced in a closure
- let value = next[key];
-
- // Up here because we want to do this for the initial value, too, even if it's undefined,
- // and this wouldn't be reached in case of undefined because of the equality check below
- if (is_option_element && key === 'value' && value == null) {
- // The