From 15e1e73f9cb7fd4a96f994816fa03be64715e428 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Sat, 28 Feb 2026 19:58:18 -0500
Subject: [PATCH 01/28] chore: add `batch.id` property (#17831)
I cannot tell you how many times I have temporarily added this code to
make it easier to debug some async stuff. I am extremely bored of doing
so. I'm just going to add it to `main` to save myself the annoyance. We
can remove it once everything async is stable
---
packages/svelte/src/internal/client/reactivity/batch.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 4a4864581d..1575d6561d 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -76,7 +76,12 @@ export let is_flushing_sync = false;
*/
export let collected_effects = null;
+let uid = 1;
+
export class Batch {
+ // for debugging. TODO remove once async is stable
+ id = uid++;
+
/**
* The current values of any sources that are updated in this batch
* They keys of this map are identical to `this.#previous`
From 4aa3777271c444022e52cceba775fc9e82a42fa4 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Sun, 1 Mar 2026 11:40:27 -0500
Subject: [PATCH 02/28] chore: better log_effect_tree (#17833)
While working on the reactivity it's very helpful to be able to log a
snapshot of the effect tree. This PR augments the existing
`log_effect_tree` helper by marking unreachable-but-dirty effects, like
so:
(I had thought `log_inconsistent_branches` was designed to help with
this but it didn't work for me. Do we need both? cc @dummdidumm)
---------
Co-authored-by: Vercel
---
.../svelte/src/internal/client/dev/debug.js | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js
index 22c4de1179..55b7247c23 100644
--- a/packages/svelte/src/internal/client/dev/debug.js
+++ b/packages/svelte/src/internal/client/dev/debug.js
@@ -72,15 +72,22 @@ function effect_label(effect, append_effect = false) {
*
* @param {Effect} effect
*/
-export function log_effect_tree(effect, depth = 0) {
+export function log_effect_tree(effect, depth = 0, is_reachable = true) {
const flags = effect.f;
- const label = effect_label(effect);
+ let label = effect_label(effect);
let status =
(flags & CLEAN) !== 0 ? 'clean' : (flags & MAYBE_DIRTY) !== 0 ? 'maybe dirty' : 'dirty';
+ let styles = [`font-weight: ${status === 'clean' ? 'normal' : 'bold'}`];
+
+ if (status !== 'clean' && !is_reachable) {
+ label = `ā ļø ${label}`;
+ styles.push(`color: red`);
+ }
+
// eslint-disable-next-line no-console
- console.group(`%c${label} (${status})`, `font-weight: ${status === 'clean' ? 'normal' : 'bold'}`);
+ console.group(`%c${label} (${status})`, styles.join('; '));
if (depth === 0) {
const callsite = new Error().stack
@@ -120,9 +127,11 @@ export function log_effect_tree(effect, depth = 0) {
}
}
+ var child_is_reachable = is_reachable && ((flags & BRANCH_EFFECT) === 0 || (flags & CLEAN) === 0);
+
let child = effect.first;
while (child !== null) {
- log_effect_tree(child, depth + 1);
+ log_effect_tree(child, depth + 1, child_is_reachable);
child = child.next;
}
From 791d5e332c08d808bc51e90d7c60cc8b05bfa1f4 Mon Sep 17 00:00:00 2001
From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:17:43 +0100
Subject: [PATCH 03/28] perf: cache element interactivity and source line
splitting (#17839)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Two small compiler optimizations that reduce redundant work:
- **Cache `element_interactivity` per element in a11y checks**:
`check_element` was calling `element_interactivity()` up to 10 times per
element (via `is_interactive_element`, `is_non_interactive_element`,
`is_static_element` wrappers), each time re-iterating schema arrays. Now
computed once after building the attribute map and reused. The
now-unused wrapper functions are removed.
- **Split source lines once in `state.set_source`**: Every compiler
warning called `get_code_frame` which split the entire source string
with `source.split('\n')`. Now the split happens once in `set_source()`
and is exported as `state.source_lines`, naturally cleared by `reset()`.
## Benchmark
Synthetic component (80 state vars, 30 each blocks, ~1300 lines):
```
Min Best3 Median
Before 66.55ms 67.03ms 73.44ms
After 61.14ms 61.90ms 70.63ms
Improvement 8.1% 7.7% 3.8%
```
Realistic component (~80 lines, ~25 elements, few warnings): **~1-4%**
improvement. The a11y cache scales with element count, the source.split
saving scales with warning count.
## Test plan
- [x] All 326 validator tests pass (includes all a11y tests)
- [x] All 5671 runtime tests pass
- [x] 145 compiler-error tests pass
š¤ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6
---
.changeset/calm-clouds-wave.md | 5 ++
.../2-analyze/visitors/shared/a11y/index.js | 57 +++++--------------
packages/svelte/src/compiler/state.js | 8 +++
.../src/compiler/utils/compile_diagnostic.js | 7 +--
4 files changed, 31 insertions(+), 46 deletions(-)
create mode 100644 .changeset/calm-clouds-wave.md
diff --git a/.changeset/calm-clouds-wave.md b/.changeset/calm-clouds-wave.md
new file mode 100644
index 0000000000..35f13d7ecb
--- /dev/null
+++ b/.changeset/calm-clouds-wave.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+perf: cache element interactivity and source line splitting in compiler
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 45de8b10a1..be3af1e59f 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
@@ -100,6 +100,11 @@ export function check_element(node, context) {
}
}
+ const interactivity = element_interactivity(node.name, attribute_map);
+ const is_interactive = interactivity === ElementInteractivity.Interactive;
+ const is_non_interactive = interactivity === ElementInteractivity.NonInteractive;
+ const is_static = interactivity === ElementInteractivity.Static;
+
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') continue;
@@ -133,7 +138,7 @@ export function check_element(node, context) {
if (
name === 'aria-activedescendant' &&
!is_dynamic_element &&
- !is_interactive_element(node.name, attribute_map) &&
+ !is_interactive &&
!attribute_map.has('tabindex') &&
!has_spread
) {
@@ -215,7 +220,7 @@ export function check_element(node, context) {
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(current_role) &&
is_interactive_roles(current_role) &&
- is_static_element(node.name, attribute_map) &&
+ is_static &&
!attribute_map.get('tabindex')
) {
const has_interactive_handlers = [...handlers].some((handler) =>
@@ -229,7 +234,7 @@ export function check_element(node, context) {
// no-interactive-element-to-noninteractive-role
if (
!has_spread &&
- is_interactive_element(node.name, attribute_map) &&
+ is_interactive &&
(is_non_interactive_roles(current_role) || is_presentation_role(current_role))
) {
w.a11y_no_interactive_element_to_noninteractive_role(node, node.name, current_role);
@@ -238,7 +243,7 @@ export function check_element(node, context) {
// no-noninteractive-element-to-interactive-role
if (
!has_spread &&
- is_non_interactive_element(node.name, attribute_map) &&
+ is_non_interactive &&
is_interactive_roles(current_role) &&
!a11y_non_interactive_element_to_interactive_role_exceptions[node.name]?.includes(
current_role
@@ -291,7 +296,7 @@ export function check_element(node, context) {
!is_dynamic_element &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
(!role || is_non_presentation_role) &&
- !is_interactive_element(node.name, attribute_map) &&
+ !is_interactive &&
!has_spread
) {
const has_key_event =
@@ -307,11 +312,7 @@ export function check_element(node, context) {
);
// no-noninteractive-tabindex
- if (
- !is_dynamic_element &&
- !is_interactive_element(node.name, attribute_map) &&
- !is_interactive_roles(role_static_value)
- ) {
+ if (!is_dynamic_element && !is_interactive && !is_interactive_roles(role_static_value)) {
const tab_index = attribute_map.get('tabindex');
const tab_index_value = get_static_text_value(tab_index);
if (tab_index && (tab_index_value === null || Number(tab_index_value) >= 0)) {
@@ -341,9 +342,8 @@ export function check_element(node, context) {
!has_contenteditable_attr &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
- ((!is_interactive_element(node.name, attribute_map) &&
- is_non_interactive_roles(role_static_value)) ||
- (is_non_interactive_element(node.name, attribute_map) && !role))
+ ((!is_interactive && is_non_interactive_roles(role_static_value)) ||
+ (is_non_interactive && !role))
) {
const has_interactive_handlers = [...handlers].some((handler) =>
a11y_recommended_interactive_handlers.includes(handler)
@@ -359,9 +359,9 @@ export function check_element(node, context) {
(!role || role_static_value !== null) &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
- !is_interactive_element(node.name, attribute_map) &&
+ !is_interactive &&
!is_interactive_roles(role_static_value) &&
- !is_non_interactive_element(node.name, attribute_map) &&
+ !is_non_interactive &&
!is_non_interactive_roles(role_static_value) &&
!is_abstract_role(role_static_value)
) {
@@ -643,33 +643,6 @@ function element_interactivity(tag_name, attribute_map) {
return ElementInteractivity.Static;
}
-/**
- * @param {string} tag_name
- * @param {Map} attribute_map
- * @returns {boolean}
- */
-function is_interactive_element(tag_name, attribute_map) {
- return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
-}
-
-/**
- * @param {string} tag_name
- * @param {Map} attribute_map
- * @returns {boolean}
- */
-function is_non_interactive_element(tag_name, attribute_map) {
- return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
-}
-
-/**
- * @param {string} tag_name
- * @param {Map} attribute_map
- * @returns {boolean}
- */
-function is_static_element(tag_name, attribute_map) {
- return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
-}
-
/**
* @param {ARIARoleDefinitionKey} role
* @param {string} tag_name
diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js
index c380143f4f..5ae001ec50 100644
--- a/packages/svelte/src/compiler/state.js
+++ b/packages/svelte/src/compiler/state.js
@@ -32,6 +32,12 @@ export let component_name = '';
*/
export let source;
+/**
+ * The source code split into lines (set by `set_source`)
+ * @type {string[]}
+ */
+export let source_lines = [];
+
/**
* True if compiling with `dev: true`
* @type {boolean}
@@ -46,6 +52,7 @@ export let locator;
/** @param {string} value */
export function set_source(value) {
source = value;
+ source_lines = source.split('\n');
const l = getLocator(source, { offsetLine: 1 });
@@ -134,6 +141,7 @@ export function reset(state) {
runes = false;
component_name = UNKNOWN_FILENAME;
source = '';
+ source_lines = [];
filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/');
warning_filter = state.warning ?? (() => true);
warnings = [];
diff --git a/packages/svelte/src/compiler/utils/compile_diagnostic.js b/packages/svelte/src/compiler/utils/compile_diagnostic.js
index c5df49e01c..95d028ee35 100644
--- a/packages/svelte/src/compiler/utils/compile_diagnostic.js
+++ b/packages/svelte/src/compiler/utils/compile_diagnostic.js
@@ -11,12 +11,11 @@ function tabs_to_spaces(str) {
}
/**
- * @param {string} source
* @param {number} line
* @param {number} column
*/
-function get_code_frame(source, line, column) {
- const lines = source.split('\n');
+function get_code_frame(line, column) {
+ const lines = state.source_lines;
const frame_start = Math.max(0, line - 2);
const frame_end = Math.min(line + 3, lines.length);
const digits = String(frame_end + 1).length;
@@ -70,7 +69,7 @@ export class CompileDiagnostic {
this.start = state.locator(position[0]);
this.end = state.locator(position[1]);
if (this.start && this.end) {
- this.frame = get_code_frame(state.source, this.start.line - 1, this.end.column);
+ this.frame = get_code_frame(this.start.line - 1, this.end.column);
}
}
}
From 7717ba01b42625f2022b7034063a25d8507560d7 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 2 Mar 2026 15:46:22 -0500
Subject: [PATCH 04/28] fix: preserve each items that are needed by pending
batches (#17819)
This fixes a longstanding TODO with each blocks: currently, if any
effects aren't used in the current batch at the moment of
reconciliation, they are destroyed. Subsequent batches therefore end up
recreating them.
This is wasteful at the best of times, but if the effect contains any
async work, that work has to be restarted.
This PR fixes it by preserving any effects that correspond to the keys
of pending batches. It _does_ mean that we need to iterate over each
`keys` map for each pending batch in which an each block re-ran, but
that is a rare scenario. This feels preferable to the alternative
approaches.
---
.changeset/spicy-teeth-tan.md | 5 ++
.../src/internal/client/dom/blocks/each.js | 64 ++++++++++++++----
.../svelte/src/internal/client/types.d.ts | 3 +
.../async-each-preserve-pending/_config.js | 65 +++++++++++++++++++
.../async-each-preserve-pending/main.svelte | 29 +++++++++
5 files changed, 153 insertions(+), 13 deletions(-)
create mode 100644 .changeset/spicy-teeth-tan.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/main.svelte
diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md
new file mode 100644
index 0000000000..4497392a9f
--- /dev/null
+++ b/.changeset/spicy-teeth-tan.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: preserve each items that are needed by pending batches
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 7ae02d073c..cac980aa9b 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -29,6 +29,7 @@ import {
block,
branch,
destroy_effect,
+ move_effect,
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
@@ -83,7 +84,7 @@ function pause_effects(state, to_destroy, controlled_anchor) {
if (group.pending.size === 0) {
var groups = /** @type {Set} */ (state.outrogroups);
- destroy_effects(array_from(group.done));
+ destroy_effects(state, array_from(group.done));
groups.delete(group);
if (groups.size === 0) {
@@ -114,7 +115,7 @@ function pause_effects(state, to_destroy, controlled_anchor) {
state.items.clear();
}
- destroy_effects(to_destroy, !fast_path);
+ destroy_effects(state, to_destroy, !fast_path);
} else {
group = {
pending: new Set(to_destroy),
@@ -126,14 +127,36 @@ function pause_effects(state, to_destroy, controlled_anchor) {
}
/**
+ * @param {EachState} state
* @param {Effect[]} to_destroy
* @param {boolean} remove_dom
*/
-function destroy_effects(to_destroy, remove_dom = true) {
- // TODO only destroy effects if no pending batch needs them. otherwise,
- // just re-add the `EFFECT_OFFSCREEN` flag
+function destroy_effects(state, to_destroy, remove_dom = true) {
+ /** @type {Set | undefined} */
+ var preserved_effects;
+
+ // The loop-in-a-loop isn't ideal, but we should only hit this in relatively rare cases
+ if (state.pending.size > 0) {
+ preserved_effects = new Set();
+
+ for (const keys of state.pending.values()) {
+ for (const key of keys) {
+ preserved_effects.add(/** @type {EachItem} */ (state.items.get(key)).e);
+ }
+ }
+ }
+
for (var i = 0; i < to_destroy.length; i++) {
- destroy_effect(to_destroy[i], remove_dom);
+ var e = to_destroy[i];
+
+ if (preserved_effects?.has(e)) {
+ e.f |= EFFECT_OFFSCREEN;
+
+ const fragment = document.createDocumentFragment();
+ move_effect(e, fragment);
+ } else {
+ destroy_effect(to_destroy[i], remove_dom);
+ }
}
}
@@ -185,9 +208,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
/** @type {V[]} */
var array;
+ /** @type {Map>} */
+ var pending = new Map();
+
var first_run = true;
- function commit() {
+ /**
+ * @param {Batch} batch
+ */
+ function commit(batch) {
+ state.pending.delete(batch);
+
state.fallback = fallback;
reconcile(state, array, anchor, flags, get_key);
@@ -210,6 +241,13 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
}
+ /**
+ * @param {Batch} batch
+ */
+ function discard(batch) {
+ state.pending.delete(batch);
+ }
+
var effect = block(() => {
array = /** @type {V[]} */ (get(each_array));
var length = array.length;
@@ -314,6 +352,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
if (!first_run) {
+ pending.set(batch, keys);
+
if (defer) {
for (const [key, item] of items) {
if (!keys.has(key)) {
@@ -322,11 +362,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
batch.oncommit(commit);
- batch.ondiscard(() => {
- // TODO presumably we need to do something here?
- });
+ batch.ondiscard(discard);
} else {
- commit();
+ commit(batch);
}
}
@@ -345,7 +383,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
});
/** @type {EachState} */
- var state = { effect, flags, items, outrogroups: null, fallback };
+ var state = { effect, flags, items, pending, outrogroups: null, fallback };
first_run = false;
@@ -544,7 +582,7 @@ function reconcile(state, array, anchor, flags, get_key) {
if (state.outrogroups !== null) {
for (const group of state.outrogroups) {
if (group.pending.size === 0) {
- destroy_effects(array_from(group.done));
+ destroy_effects(state, array_from(group.done));
state.outrogroups?.delete(group);
}
}
diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts
index 443c21010e..f4fc81170d 100644
--- a/packages/svelte/src/internal/client/types.d.ts
+++ b/packages/svelte/src/internal/client/types.d.ts
@@ -1,5 +1,6 @@
import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js';
+import type { Batch } from './reactivity/batch.js';
import type { Effect, Source, Value } from './reactivity/types.js';
declare global {
@@ -84,6 +85,8 @@ export type EachState = {
flags: number;
/** a key -> item lookup */
items: Map;
+ /** a batch -> keys lookup of all keys that are still needed */
+ pending: Map>;
/** all outro groups that this item is a part of */
outrogroups: Set | null;
/** `{:else}` effect */
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js
new file mode 100644
index 0000000000..c4efa873bf
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js
@@ -0,0 +1,65 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+
+ const [add, shift] = target.querySelectorAll('button');
+
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+ add.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ 1
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ 1
+ 2
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ 1
+ 2
+ 3
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add
+ shift
+ 1
+ 2
+ 3
+ 4
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/main.svelte
new file mode 100644
index 0000000000..55d894d240
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/main.svelte
@@ -0,0 +1,29 @@
+
+
+add
+shift
+
+{#each values as v}
+ {await push(v)}
+{/each}
From 86ec2108668305ff9c0e5a2d67d888b86cb4874d Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 2 Mar 2026 15:59:22 -0500
Subject: [PATCH 05/28] fix: correctly add `__svelte_meta` after else-if chains
(#17830)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
While looking into something else I spotted the fact that we use `false`
to indicate 'else' in an `{#if ...}` block (whether there's an `else`
block to render or not). If we instead use `-1`, and use `` to
indicate that the first block in an if-elseif chain was rendered...
- instead of ``, we do ``
- instead of ``, we do ``
- all others stay the same
...we can simplify things a bit ā the `key` argument to `update_branch`
is always a number (which probably has some microscopic benefits in
terms of making it monomorphic when `else` is defined, and less
polymorphic when it isn't), and the hydration mismatch code only needs
to consider one type of hydration marker.
In the process, I discovered a bug ā the dev-time `add_locations`
function fails on hydration markers like ``. This PR fixes it.
---
.changeset/breezy-jokes-laugh.md | 5 +++
.../3-transform/client/visitors/IfBlock.js | 4 +--
.../3-transform/server/visitors/IfBlock.js | 8 ++---
.../src/internal/client/dev/elements.js | 4 +--
.../src/internal/client/dom/blocks/if.js | 19 ++--------
.../samples/svelte-meta-if-else/_config.js | 35 +++++++++++++++++++
.../samples/svelte-meta-if-else/main.svelte | 11 ++++++
.../_expected/server/index.svelte.js | 4 +--
.../_expected/client/index.svelte.js | 2 +-
.../_expected/server/index.svelte.js | 4 +--
.../_expected/client/index.svelte.js | 14 ++++----
.../_expected/server/index.svelte.js | 28 +++++++--------
.../_expected/client/index.svelte.js | 2 +-
.../_expected/server/index.svelte.js | 4 +--
.../_expected/server/index.svelte.js | 4 +--
.../_expected/server/index.svelte.js | 12 +++----
16 files changed, 99 insertions(+), 61 deletions(-)
create mode 100644 .changeset/breezy-jokes-laugh.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/main.svelte
diff --git a/.changeset/breezy-jokes-laugh.md b/.changeset/breezy-jokes-laugh.md
new file mode 100644
index 0000000000..b8ab5b32af
--- /dev/null
+++ b/.changeset/breezy-jokes-laugh.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correctly add \_\_svelte_meta after else-if chains
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
index 0d31c42d11..b0cd51ce3f 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
@@ -51,7 +51,7 @@ export function IfBlock(node, context) {
}
}
- const render_call = b.stmt(b.call('$$render', consequent_id, index > 0 && b.literal(index)));
+ const render_call = b.stmt(b.call('$$render', consequent_id, index !== 0 && b.literal(index)));
const new_if = b.if(test, render_call);
if (last_if) {
@@ -71,7 +71,7 @@ export function IfBlock(node, context) {
const alternate_id = b.id(context.state.scope.generate('alternate'));
statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate)));
- last_if.alternate = b.stmt(b.call('$$render', alternate_id, b.literal(false)));
+ last_if.alternate = b.stmt(b.call('$$render', alternate_id, b.literal(-1)));
}
// Build $.if() arguments
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js
index e3f5f88705..2d1a22605c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js
@@ -1,8 +1,8 @@
-/** @import { BlockStatement, Expression, IfStatement, Statement } from 'estree' */
+/** @import { BlockStatement, Expression, IfStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
-import { block_close, block_open, block_open_else, create_child_block } from './shared/utils.js';
+import { block_close, create_child_block } from './shared/utils.js';
/**
* @param {AST.IfBlock} node
@@ -10,7 +10,7 @@ import { block_close, block_open, block_open_else, create_child_block } from './
*/
export function IfBlock(node, context) {
const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
- consequent.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open)));
+ consequent.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(``))));
/** @type {IfStatement} */
let if_statement = b.if(/** @type {Expression} */ (context.visit(node.test)), consequent);
@@ -34,7 +34,7 @@ export function IfBlock(node, context) {
// Handle final else (or remaining async chain)
const final_alternate = alt ? /** @type {BlockStatement} */ (context.visit(alt)) : b.block([]);
- final_alternate.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open_else)));
+ final_alternate.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(``))));
current_if.alternate = final_alternate;
context.state.template.push(
diff --git a/packages/svelte/src/internal/client/dev/elements.js b/packages/svelte/src/internal/client/dev/elements.js
index 8dd54e0a2a..df765607a0 100644
--- a/packages/svelte/src/internal/client/dev/elements.js
+++ b/packages/svelte/src/internal/client/dev/elements.js
@@ -1,6 +1,6 @@
/** @import { SourceLocation } from '#client' */
import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/constants';
-import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js';
+import { HYDRATION_END, HYDRATION_START } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';
import { dev_stack } from '../context.js';
@@ -50,7 +50,7 @@ function assign_locations(node, filename, locations) {
while (node && i < locations.length) {
if (hydrating && node.nodeType === COMMENT_NODE) {
var comment = /** @type {Comment} */ (node);
- if (comment.data === HYDRATION_START || comment.data === HYDRATION_START_ELSE) depth += 1;
+ if (comment.data[0] === HYDRATION_START) depth += 1;
else if (comment.data[0] === HYDRATION_END) depth -= 1;
}
diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js
index 44f5fdd1bc..7d3f9f7fbd 100644
--- a/packages/svelte/src/internal/client/dom/blocks/if.js
+++ b/packages/svelte/src/internal/client/dom/blocks/if.js
@@ -11,7 +11,6 @@ import {
} from '../hydration.js';
import { block } from '../../reactivity/effects.js';
import { BranchManager } from './branches.js';
-import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
/**
* @param {TemplateNode} node
@@ -37,21 +36,9 @@ export function if_block(node, fn, elseif = false) {
function update_branch(key, fn) {
if (hydrating) {
var data = read_hydration_instruction(/** @type {TemplateNode} */ (marker));
- /**
- * @type {number | false}
- * "[" = branch 0, "[1" = branch 1, "[2" = branch 2, ..., "[!" = else (false)
- */
- var hydrated_key;
- if (data === HYDRATION_START) {
- hydrated_key = 0;
- } else if (data === HYDRATION_START_ELSE) {
- hydrated_key = false;
- } else {
- hydrated_key = parseInt(data.substring(1)); // "[1", "[2", etc.
- }
-
- if (key !== hydrated_key) {
+ // "[n" = branch n, "[-1" = else
+ if (key !== parseInt(data.substring(1))) {
// Hydration mismatch: remove everything inside the anchor and start fresh.
// This could happen with `{#if browser}...{/if}`, for example
var anchor = skip_nodes();
@@ -79,7 +66,7 @@ export function if_block(node, fn, elseif = false) {
});
if (!has_branch) {
- update_branch(false, null);
+ update_branch(-1, null);
}
}, flags);
}
diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/_config.js
new file mode 100644
index 0000000000..114ebd36c8
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/_config.js
@@ -0,0 +1,35 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+
+ html: `before
during
after
`,
+
+ async test({ target, assert }) {
+ const ps = target.querySelectorAll('p');
+
+ // @ts-expect-error
+ assert.deepEqual(ps[0].__svelte_meta.loc, {
+ file: 'main.svelte',
+ line: 1,
+ column: 0
+ });
+
+ // @ts-expect-error
+ assert.deepEqual(ps[1].__svelte_meta.loc, {
+ file: 'main.svelte',
+ line: 6,
+ column: 1
+ });
+
+ // @ts-expect-error
+ assert.deepEqual(ps[2].__svelte_meta.loc, {
+ file: 'main.svelte',
+ line: 11,
+ column: 0
+ });
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/main.svelte
new file mode 100644
index 0000000000..d0789b8ba8
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta-if-else/main.svelte
@@ -0,0 +1,11 @@
+before
+
+{#if false}
+ during
+{:else if true}
+ during
+{:else if false}
+ during
+{/if}
+
+after
diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
index 03bbc5ba88..5f3cfa6ca6 100644
--- a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js
@@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Async_const($$renderer) {
if (true) {
- $$renderer.push('');
+ $$renderer.push('');
let a;
let b;
@@ -22,7 +22,7 @@ export default function Async_const($$renderer) {
$$renderer.async([promises[1]], ($$renderer) => $$renderer.push(() => $.escape(b)));
$$renderer.push(`
`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
}
$$renderer.push(``);
diff --git a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js
index d86001e273..52d92ba531 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js
@@ -22,7 +22,7 @@ export default function Async_if_alternate_hoisting($$anchor) {
};
$.if(node, ($$render) => {
- if ($.get($$condition)) $$render(consequent); else $$render(alternate, false);
+ if ($.get($$condition)) $$render(consequent); else $$render(alternate, -1);
});
});
diff --git a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js
index c69c038973..f13c1b6a25 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js
@@ -4,10 +4,10 @@ import * as $ from 'svelte/internal/server';
export default function Async_if_alternate_hoisting($$renderer) {
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(Promise.resolve(false)))()) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(async () => $.escape(await Promise.reject('no no no')));
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes')));
}
});
diff --git a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js
index b55e3010b8..8d21c2ceef 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js
@@ -35,7 +35,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node, ($$render) => {
- if (foo) $$render(consequent); else if (bar) $$render(consequent_1, 1); else $$render(alternate, false);
+ if (foo) $$render(consequent); else if (bar) $$render(consequent_1, 1); else $$render(alternate, -1);
});
});
@@ -74,7 +74,7 @@ export default function Async_if_chain($$anchor) {
$.if(
node_2,
($$render) => {
- if ($.get($$condition)) $$render(consequent_4); else $$render(alternate_1, false);
+ if ($.get($$condition)) $$render(consequent_4); else $$render(alternate_1, -1);
},
true
);
@@ -84,7 +84,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_1, ($$render) => {
- if ($.get($$condition)) $$render(consequent_2); else if (bar) $$render(consequent_3, 1); else $$render(alternate_2, false);
+ if ($.get($$condition)) $$render(consequent_2); else if (bar) $$render(consequent_3, 1); else $$render(alternate_2, -1);
});
});
@@ -123,7 +123,7 @@ export default function Async_if_chain($$anchor) {
$.if(
node_4,
($$render) => {
- if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, false);
+ if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, -1);
},
true
);
@@ -133,7 +133,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_3, ($$render) => {
- if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, false);
+ if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, -1);
});
});
@@ -167,7 +167,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_5, ($$render) => {
- if (simple1) $$render(consequent_8); else if (simple2 > 10) $$render(consequent_9, 1); else if ($.get(d)) $$render(consequent_10, 2); else $$render(alternate_5, false);
+ if (simple1) $$render(consequent_8); else if (simple2 > 10) $$render(consequent_9, 1); else if ($.get(d)) $$render(consequent_10, 2); else $$render(alternate_5, -1);
});
}
@@ -193,7 +193,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_6, ($$render) => {
- if ($.get(blocking) > 10) $$render(consequent_11); else if ($.get(blocking) > 5) $$render(consequent_12, 1); else $$render(alternate_6, false);
+ if ($.get(blocking) > 10) $$render(consequent_11); else if ($.get(blocking) > 5) $$render(consequent_12, 1); else $$render(alternate_6, -1);
});
});
diff --git a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/server/index.svelte.js
index 1b1ab51d6f..3fc25288ad 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/server/index.svelte.js
@@ -12,13 +12,13 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], ($$renderer) => {
if (foo) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('');
$$renderer.push(`bar`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`else`);
}
});
@@ -27,20 +27,20 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))()) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('');
$$renderer.push(`bar`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(baz))()) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`baz`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`else`);
}
});
@@ -53,20 +53,20 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))() > 10) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('');
$$renderer.push(`bar`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))() > 5) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`baz`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`else`);
}
});
@@ -78,7 +78,7 @@ export default function Async_if_chain($$renderer) {
$$renderer.push(` `);
if (simple1) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`foo`);
} else if (simple2 > 10) {
$$renderer.push('');
@@ -87,7 +87,7 @@ export default function Async_if_chain($$renderer) {
$$renderer.push('');
$$renderer.push(`baz`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`else`);
}
@@ -95,13 +95,13 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], ($$renderer) => {
if (blocking() > 10) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`foo`);
} else if (blocking() > 5) {
$$renderer.push('');
$$renderer.push(`bar`);
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(`else`);
}
});
diff --git a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js
index 5cdb6978d9..e65609dca1 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js
@@ -22,7 +22,7 @@ export default function Async_if_hoisting($$anchor) {
};
$.if(node, ($$render) => {
- if ($.get($$condition)) $$render(consequent); else $$render(alternate, false);
+ if ($.get($$condition)) $$render(consequent); else $$render(alternate, -1);
});
});
diff --git a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js
index 1355ba34f0..bc5f53a058 100644
--- a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js
@@ -4,10 +4,10 @@ import * as $ from 'svelte/internal/server';
export default function Async_if_hoisting($$renderer) {
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(Promise.resolve(true)))()) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes')));
} else {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(async () => $.escape(await Promise.reject('no no no')));
}
});
diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js
index 3af9f504ec..1697e3adc6 100644
--- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js
@@ -18,7 +18,7 @@ export default function Async_in_derived($$renderer, $$props) {
]);
if (true) {
- $$renderer.push('');
+ $$renderer.push('');
let yes1;
let yes2;
@@ -47,7 +47,7 @@ export default function Async_in_derived($$renderer, $$props) {
}
]);
} else {
- $$renderer.push('');
+ $$renderer.push('');
}
$$renderer.push(``);
diff --git a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/server/index.svelte.js
index a50200a769..bc346b4b0a 100644
--- a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/server/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/server/index.svelte.js
@@ -57,13 +57,13 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(` `);
if (show) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Visible`);
});
} else {
- $$renderer.push('');
+ $$renderer.push('');
}
$$renderer.push(` `);
@@ -148,7 +148,7 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(` `);
if (show) {
- $$renderer.push('');
+ $$renderer.push('');
$$renderer.push(``);
const each_array_4 = $.ensure_array_like(items);
@@ -161,7 +161,7 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(``);
} else {
- $$renderer.push('');
+ $$renderer.push('');
}
$$renderer.push(` `);
@@ -227,10 +227,10 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(` `);
if (show) {
- $$renderer.push('');
+ $$renderer.push('');
conditional_option($$renderer);
} else {
- $$renderer.push('');
+ $$renderer.push('');
}
$$renderer.push(` `);
From 00dc13db1496142d3d62d98655cf8a54ba67e468 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 2 Mar 2026 16:05:13 -0500
Subject: [PATCH 06/28] chore: highlight effect in tree (#17835)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Another QoL improvement to `log_effect_tree` ā if you pass an array of
effects as the second argument, it will highlight them in the tree. It
will also italicise any effects that currently have the `INERT` flag.
---
packages/svelte/src/internal/client/dev/debug.js | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js
index 55b7247c23..83cc510ae2 100644
--- a/packages/svelte/src/internal/client/dev/debug.js
+++ b/packages/svelte/src/internal/client/dev/debug.js
@@ -68,11 +68,12 @@ function effect_label(effect, append_effect = false) {
return label;
}
+
/**
- *
* @param {Effect} effect
+ * @param {Effect[]} highlighted
*/
-export function log_effect_tree(effect, depth = 0, is_reachable = true) {
+export function log_effect_tree(effect, highlighted = [], depth = 0, is_reachable = true) {
const flags = effect.f;
let label = effect_label(effect);
@@ -86,6 +87,14 @@ export function log_effect_tree(effect, depth = 0, is_reachable = true) {
styles.push(`color: red`);
}
+ if ((flags & INERT) !== 0) {
+ styles.push('font-style: italic');
+ }
+
+ if (highlighted.includes(effect)) {
+ styles.push('background-color: yellow');
+ }
+
// eslint-disable-next-line no-console
console.group(`%c${label} (${status})`, styles.join('; '));
@@ -131,7 +140,7 @@ export function log_effect_tree(effect, depth = 0, is_reachable = true) {
let child = effect.first;
while (child !== null) {
- log_effect_tree(child, depth + 1, child_is_reachable);
+ log_effect_tree(child, highlighted, depth + 1, child_is_reachable);
child = child.next;
}
From 2f12b6070107c1457a2f0b9d7ac652aee9e2394c Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Mon, 2 Mar 2026 19:27:48 -0500
Subject: [PATCH 07/28] chore: avoid reschedule during branch commit (#17837)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
If a dirty effect is resumed ā for example `condition` becomes `false`
then `count` changes then `condition` becomes `true` again...
```svelte
{#if condition}
{count}
{/if}
```
...then the effect is rescheduled. This happens when branches are
committed (after the effect tree is traversed, before effects are
flushed).
That's undesirable, because it causes another turn of the
`flush_effects` loop. It's better if we can handle everything in a
single pass, which is what happens in this PR. The trade-off is that we
have to traverse the entire effect tree, instead of skipping inert
subtrees, which is a trade-off that I think makes sense.
The real agenda here is that I'm trying to eliminate all
`schedule_effect` calls that happen at inconvenient times, because I
have a hunch that if we do that we can return to #17805, which I'm
increasingly convinced will be important. (You might have to trust me on
this; a full explanation would look a bit charlie-day-meme.jpg. Call it
a hunch.)
### Before submitting the PR, please make sure you do the following
- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [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: Vercel
---
.changeset/curvy-stars-drop.md | 5 +++++
.../src/internal/client/dom/blocks/branches.js | 6 +++++-
.../src/internal/client/reactivity/batch.js | 16 ++++++++++++----
.../src/internal/client/reactivity/effects.js | 12 +++++-------
4 files changed, 27 insertions(+), 12 deletions(-)
create mode 100644 .changeset/curvy-stars-drop.md
diff --git a/.changeset/curvy-stars-drop.md b/.changeset/curvy-stars-drop.md
new file mode 100644
index 0000000000..bbbb7a0499
--- /dev/null
+++ b/.changeset/curvy-stars-drop.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+chore: avoid rescheduling effects during branch commit
diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js
index a8096e0a58..344b8e9c04 100644
--- a/packages/svelte/src/internal/client/dom/blocks/branches.js
+++ b/packages/svelte/src/internal/client/dom/blocks/branches.js
@@ -1,4 +1,5 @@
/** @import { Effect, TemplateNode } from '#client' */
+import { INERT } from '#client/constants';
import { Batch, current_batch } from '../../reactivity/batch.js';
import {
branch,
@@ -87,7 +88,7 @@ export class BranchManager {
// effect is currently offscreen. put it in the DOM
var offscreen = this.#offscreen.get(key);
- if (offscreen) {
+ if (offscreen && (offscreen.effect.f & INERT) === 0) {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
@@ -124,6 +125,9 @@ export class BranchManager {
// or those that are already outroing (else the transition is aborted and the effect destroyed right away)
if (k === key || this.#outroing.has(k)) continue;
+ // don't destroy branches that are inside outroing blocks
+ if ((effect.f & INERT) !== 0) continue;
+
const on_destroy = () => {
const keys = Array.from(this.#batches.values());
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 1575d6561d..73e4a30fa4 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -266,18 +266,26 @@ export class Batch {
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
- var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
+ var inert = (flags & INERT) !== 0;
+ var skip = is_skippable_branch || this.#skipped_branches.has(effect);
if (!skip && effect.fn !== null) {
if (is_branch) {
- effect.f ^= CLEAN;
+ if (!inert) effect.f ^= CLEAN;
} else if ((flags & EFFECT) !== 0) {
effects.push(effect);
- } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
+ } else if ((flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0 && (async_mode_flag || inert)) {
render_effects.push(effect);
} else if (is_dirty(effect)) {
- if ((flags & BLOCK_EFFECT) !== 0) this.#maybe_dirty_effects.add(effect);
update_effect(effect);
+
+ if ((flags & BLOCK_EFFECT) !== 0) {
+ this.#maybe_dirty_effects.add(effect);
+
+ // if this is inside an outroing block, ensure that the block
+ // re-runs if the outro is later aborted
+ if (inert) set_signal_status(effect, DIRTY);
+ }
}
var child = effect.first;
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index b670e7ab55..b3d37659ea 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -44,6 +44,7 @@ import { Batch, collected_effects, schedule_effect } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
+import { async_mode_flag } from '../../flags/index.js';
/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
@@ -665,13 +666,10 @@ function resume_children(effect, local) {
if ((effect.f & INERT) === 0) return;
effect.f ^= INERT;
- // If a dependency of this effect changed while it was paused,
- // schedule the effect to update. we don't use `is_dirty`
- // here because we don't want to eagerly recompute a derived like
- // `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined
- if ((effect.f & CLEAN) === 0) {
- set_signal_status(effect, DIRTY);
- schedule_effect(effect);
+ // Mark branches as clean so that effects can be scheduled, but only in async mode
+ // (in legacy mode, effect resumption happens during traversal)
+ if (async_mode_flag && (effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) === 0) {
+ effect.f ^= CLEAN;
}
var child = effect.first;
From 32111f9e847a5bfb33bdd9a125c368cbaf1580b1 Mon Sep 17 00:00:00 2001
From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:38:26 +0100
Subject: [PATCH 08/28] =?UTF-8?q?perf:=20avoid=20O(n=C2=B2)=20name=20scann?=
=?UTF-8?q?ing=20in=20scope=20generate/unique=20(#17844)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
`Scope.generate()` and `ScopeRoot.unique()` search for available names
by iterating from suffix `_1` upward. When the same preferred name is
generated many times (e.g. `text` is generated 482 times in a large
component), the Nth call re-scans all N-1 already-taken names ā O(n²)
total work.
This adds a `#name_counters` Map to `ScopeRoot` that tracks the next
suffix to try per name, so each call resumes from where the last one
left off. Generated names are identical to before.
## Benchmark (interleaved, best-of-3 rounds)
| Component | Min | Median |
|---|---|---|
| Realistic (~80 lines) | ~1% | ~7% |
| Medium (316 lines) | ~1% | ~5% |
| Large (642 lines) | ~7% | ~2% |
| XLarge (1302 lines) | **~11%** | **~10%** |
## Test plan
- [x] All snapshot tests pass (name generation unchanged)
- [x] All validator, compiler-error, runtime-runes, runtime-legacy tests
pass
š¤ Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6
---
.changeset/smooth-pens-jump.md | 5 ++
packages/svelte/src/compiler/phases/scope.js | 50 ++++++++++++++++++--
2 files changed, 51 insertions(+), 4 deletions(-)
create mode 100644 .changeset/smooth-pens-jump.md
diff --git a/.changeset/smooth-pens-jump.md b/.changeset/smooth-pens-jump.md
new file mode 100644
index 0000000000..79aea8e6e1
--- /dev/null
+++ b/.changeset/smooth-pens-jump.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+perf: avoid O(n²) name scanning in scope `generate` and `unique`
diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js
index 9d563375d7..e3560753b4 100644
--- a/packages/svelte/src/compiler/phases/scope.js
+++ b/packages/svelte/src/compiler/phases/scope.js
@@ -713,8 +713,18 @@ export class Scope {
}
preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[0-9]/, '_');
- let name = preferred_name;
- let n = 1;
+
+ // Use cached counter to skip names already known to be taken (avoids O(n²) scanning)
+ let n = this.root.next_counter(preferred_name);
+ let name;
+
+ if (n === 0) {
+ name = preferred_name;
+ n = 1;
+ } else {
+ name = `${preferred_name}_${n}`;
+ n++;
+ }
while (
this.references.has(name) ||
@@ -725,6 +735,7 @@ export class Scope {
name = `${preferred_name}_${n++}`;
}
+ this.root.set_counter(preferred_name, n);
this.references.set(name, []);
this.root.conflicts.add(name);
return name;
@@ -852,18 +863,49 @@ export class ScopeRoot {
/** @type {Set} */
conflicts = new Set();
+ /**
+ * Tracks the next suffix counter per name to avoid O(n) rescanning in generate/unique.
+ * @type {Map}
+ */
+ #name_counters = new Map();
+
+ /**
+ * @param {string} name
+ * @returns {number}
+ */
+ next_counter(name) {
+ return this.#name_counters.get(name) ?? 0;
+ }
+
+ /**
+ * @param {string} name
+ * @param {number} value
+ */
+ set_counter(name, value) {
+ this.#name_counters.set(name, value);
+ }
+
/**
* @param {string} preferred_name
*/
unique(preferred_name) {
preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_');
- let final_name = preferred_name;
- let n = 1;
+ let n = this.#name_counters.get(preferred_name) ?? 0;
+ let final_name;
+
+ if (n === 0) {
+ final_name = preferred_name;
+ n = 1;
+ } else {
+ final_name = `${preferred_name}_${n}`;
+ n++;
+ }
while (this.conflicts.has(final_name)) {
final_name = `${preferred_name}_${n++}`;
}
+ this.#name_counters.set(preferred_name, n);
this.conflicts.add(final_name);
const id = b.id(final_name);
return id;
From 0965028d3b1416744dde2c03bc07f6853f2aa5d0 Mon Sep 17 00:00:00 2001
From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:49:36 +0100
Subject: [PATCH 09/28] perf: optimize CSS selector pruning (#17846)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Reduces CSS selector pruning overhead by eliminating unnecessary
allocations and redundant work in the hot path.
**Changes:**
- Replace `.slice()` + `.shift()`/`.pop()` in `apply_selector` with
index-based `from`/`to` params ā avoids O(n) array copies per recursive
call
- Merge `has_selectors`/`other_selectors` split in
`relative_selector_might_apply_to_node` into a single pass ā eliminates
2 temporary array allocations per call
- Hoist `name.toLowerCase()` out of the inner loop in
`attribute_matches`
- Replace `value.split(/\s/).includes()` with `indexOf` + boundary
checks in `test_attribute` for `~=` ā avoids array allocation on every
class match
- Skip `name.replace()` regex when selector name has no backslash
## Benchmark
Interleaved benchmark (5 rounds, alternating baseline/optimized):
```
--- Round 1 ---
Baseline: min=59.58ms median=64.63ms
Optimized: min=47.20ms median=54.35ms
--- Round 2 ---
Baseline: min=59.11ms median=64.90ms
Optimized: min=48.36ms median=54.74ms
--- Round 3 ---
Baseline: min=58.38ms median=64.06ms
Optimized: min=48.38ms median=53.83ms
--- Round 4 ---
Baseline: min=58.49ms median=63.99ms
Optimized: min=48.45ms median=53.82ms
--- Round 5 ---
Baseline: min=58.40ms median=64.07ms
Optimized: min=48.97ms median=54.64ms
Best min: Before=58.38ms After=47.20ms Improvement=19.1%
Best median: Before=63.99ms After=53.82ms Improvement=15.9%
```
CPU profile before ā after:
| Function | Before | After |
|---|---|---|
| `relative_selector_might_apply_to_node` | 14.3% | 5.2% |
| `attribute_matches` | 4.0% | 3.3% |
| `test_attribute` | 3.2% | <0.9% |
## Test plan
- [x] All 196 CSS tests pass (180 samples + 16 parse)
- [x] All 31 snapshot tests pass
- [x] All 2380 runtime-runes tests pass
- [x] All 3291 runtime-legacy tests pass
- [x] All 145 compiler-errors tests pass
- [x] All 326 validator tests pass
- [x] Added `css-prune-edge-cases` test covering: `~=` word matching
(substring vs whole word), deep combinator chains (4+ levels), `:has()`
combined with class selectors, escaped selectors,
`:is()`/`:where()`/`:not()` with combinators
- [x] Edge case test passes on both baseline and optimized code
š¤ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: Rich Harris
---
.changeset/fast-css-prune.md | 5 +
.../phases/2-analyze/css/css-prune.js | 139 +++++++++++-------
.../samples/css-prune-edge-cases/_config.js | 48 ++++++
.../samples/css-prune-edge-cases/expected.css | 80 ++++++++++
.../samples/css-prune-edge-cases/input.svelte | 125 ++++++++++++++++
5 files changed, 347 insertions(+), 50 deletions(-)
create mode 100644 .changeset/fast-css-prune.md
create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js
create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css
create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/input.svelte
diff --git a/.changeset/fast-css-prune.md b/.changeset/fast-css-prune.md
new file mode 100644
index 0000000000..7b52272f9e
--- /dev/null
+++ b/.changeset/fast-css-prune.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+perf: optimize CSS selector pruning
diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
index 24da276ed5..39f485a9f7 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
@@ -236,16 +236,36 @@ function truncate(node) {
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Direction} direction
+ * @param {number} [from]
+ * @param {number} [to]
* @returns {boolean}
*/
-function apply_selector(relative_selectors, rule, element, direction) {
- const rest_selectors = relative_selectors.slice();
- const relative_selector = direction === FORWARD ? rest_selectors.shift() : rest_selectors.pop();
+function apply_selector(
+ relative_selectors,
+ rule,
+ element,
+ direction,
+ from = 0,
+ to = relative_selectors.length
+) {
+ if (from >= to) return false;
+
+ const selector_index = direction === FORWARD ? from : to - 1;
+ const relative_selector = relative_selectors[selector_index];
+ const rest_from = direction === FORWARD ? from + 1 : from;
+ const rest_to = direction === FORWARD ? to : to - 1;
const matched =
- !!relative_selector &&
relative_selector_might_apply_to_node(relative_selector, rule, element, direction) &&
- apply_combinator(relative_selector, rest_selectors, rule, element, direction);
+ apply_combinator(
+ relative_selector,
+ relative_selectors,
+ rest_from,
+ rest_to,
+ rule,
+ element,
+ direction
+ );
if (matched) {
if (!is_outer_global(relative_selector)) {
@@ -260,15 +280,21 @@ function apply_selector(relative_selectors, rule, element, direction) {
/**
* @param {Compiler.AST.CSS.RelativeSelector} relative_selector
- * @param {Compiler.AST.CSS.RelativeSelector[]} rest_selectors
+ * @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
+ * @param {number} from
+ * @param {number} to
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement | Compiler.AST.RenderTag | Compiler.AST.Component | Compiler.AST.SvelteComponent | Compiler.AST.SvelteSelf} node
* @param {Direction} direction
* @returns {boolean}
*/
-function apply_combinator(relative_selector, rest_selectors, rule, node, direction) {
+function apply_combinator(relative_selector, relative_selectors, from, to, rule, node, direction) {
const combinator =
- direction == FORWARD ? rest_selectors[0]?.combinator : relative_selector.combinator;
+ direction == FORWARD
+ ? from < to
+ ? relative_selectors[from].combinator
+ : undefined
+ : relative_selector.combinator;
if (!combinator) return true;
switch (combinator.name) {
@@ -282,7 +308,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
let parent_matched = false;
for (const parent of parents) {
- if (apply_selector(rest_selectors, rule, parent, direction)) {
+ if (apply_selector(relative_selectors, rule, parent, direction, from, to)) {
parent_matched = true;
}
}
@@ -291,7 +317,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
parent_matched ||
(direction === BACKWARD &&
(!is_adjacent || parents.length === 0) &&
- rest_selectors.every((selector) => is_global(selector, rule)))
+ every_is_global(relative_selectors, from, to, rule))
);
}
@@ -308,10 +334,12 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
possible_sibling.type === 'Component'
) {
// `{@render foo()}foo
` with `:global(.x) + p` is a match
- if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
+ if (to - from === 1 && relative_selectors[from].metadata.is_global) {
sibling_matched = true;
}
- } else if (apply_selector(rest_selectors, rule, possible_sibling, direction)) {
+ } else if (
+ apply_selector(relative_selectors, rule, possible_sibling, direction, from, to)
+ ) {
sibling_matched = true;
}
}
@@ -320,7 +348,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
sibling_matched ||
(direction === BACKWARD &&
get_element_parent(node) === null &&
- rest_selectors.every((selector) => is_global(selector, rule)))
+ every_is_global(relative_selectors, from, to, rule))
);
}
@@ -330,6 +358,20 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
}
}
+/**
+ * @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
+ * @param {number} from
+ * @param {number} to
+ * @param {Compiler.AST.CSS.Rule} rule
+ * @returns {boolean}
+ */
+function every_is_global(relative_selectors, from, to, rule) {
+ for (let i = from; i < to; i++) {
+ if (!is_global(relative_selectors[i], rule)) return false;
+ }
+ return true;
+}
+
/**
* Returns `true` if the relative selector is global, meaning
* it's a `:global(...)` or unscopeable selector, or
@@ -392,42 +434,37 @@ const regex_backslash_and_following_character = /\\(.)/g;
* @returns {boolean}
*/
function relative_selector_might_apply_to_node(relative_selector, rule, element, direction) {
- // Sort :has(...) selectors in one bucket and everything else into another
- const has_selectors = [];
- const other_selectors = [];
+ /** @type {boolean | undefined} */
+ let include_self;
for (const selector of relative_selector.selectors) {
+ // Handle :has(...) selectors inline to avoid allocating temporary arrays
if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
- has_selectors.push(selector);
- } else {
- other_selectors.push(selector);
- }
- }
-
- // If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
- // In that case ignore this check (because we just came from this) to avoid an infinite loop.
- if (has_selectors.length > 0) {
- // If this is a :has inside a global selector, we gotta include the element itself, too,
- // because the global selector might be for an element that's outside the component,
- // e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
- const rules = get_parent_rules(rule);
- const include_self =
- rules.some((r) => r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))) ||
- rules[rules.length - 1].prelude.children.some((c) =>
- c.children.some((r) =>
- r.selectors.some(
- (s) =>
- s.type === 'PseudoClassSelector' &&
- (s.name === 'root' || (s.name === 'global' && s.args))
- )
- )
- );
+ // Lazy-compute include_self on first :has encounter
+ if (include_self === undefined) {
+ // If this is a :has inside a global selector, we gotta include the element itself, too,
+ // because the global selector might be for an element that's outside the component,
+ // e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
+ const rules = get_parent_rules(rule);
+ include_self =
+ rules.some((r) =>
+ r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))
+ ) ||
+ rules[rules.length - 1].prelude.children.some((c) =>
+ c.children.some((r) =>
+ r.selectors.some(
+ (s) =>
+ s.type === 'PseudoClassSelector' &&
+ (s.name === 'root' || (s.name === 'global' && s.args))
+ )
+ )
+ );
+ }
- // :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
- // upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
- // selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
- for (const has_selector of has_selectors) {
- const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (has_selector.args)
+ // :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
+ // upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
+ // selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
+ const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (selector.args)
.children;
let matched = false;
@@ -465,13 +502,15 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
if (!matched) {
return false;
}
+
+ continue;
}
- }
- for (const selector of other_selectors) {
if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
- const name = selector.name.replace(regex_backslash_and_following_character, '$1');
+ const name = selector.name.includes('\\')
+ ? selector.name.replace(regex_backslash_and_following_character, '$1')
+ : selector.name;
switch (selector.type) {
case 'PseudoClassSelector': {
@@ -672,11 +711,11 @@ function test_attribute(operator, expected_value, case_insensitive, value) {
* @param {boolean} case_insensitive
*/
function attribute_matches(node, name, expected_value, operator, case_insensitive) {
+ const name_lower = name.toLowerCase();
+
for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute') return true;
if (attribute.type === 'BindDirective' && attribute.name === name) return true;
-
- const name_lower = name.toLowerCase();
// match attributes against the corresponding directive but bail out on exact matching
if (attribute.type === 'StyleDirective' && name_lower === 'style') return true;
if (attribute.type === 'ClassDirective' && name_lower === 'class') {
diff --git a/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js b/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js
new file mode 100644
index 0000000000..c9d3f0cb5a
--- /dev/null
+++ b/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js
@@ -0,0 +1,48 @@
+import { test } from '../../test';
+
+export default test({
+ warnings: [
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector ".foob"',
+ start: {
+ line: 64,
+ column: 1,
+ character: 1574
+ },
+ end: {
+ line: 64,
+ column: 6,
+ character: 1579
+ }
+ },
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector "main > article > div > section > span"',
+ start: {
+ line: 84,
+ column: 1,
+ character: 2196
+ },
+ end: {
+ line: 84,
+ column: 38,
+ character: 2233
+ }
+ },
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector "nav:has(button).primary"',
+ start: {
+ line: 95,
+ column: 1,
+ character: 2560
+ },
+ end: {
+ line: 95,
+ column: 24,
+ character: 2583
+ }
+ }
+ ]
+});
diff --git a/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css b/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css
new file mode 100644
index 0000000000..dbacb796bb
--- /dev/null
+++ b/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css
@@ -0,0 +1,80 @@
+
+ /* === ~= word matching === */
+
+ /* Should match: "foo" is a whole word in class="foo bar" */
+ .foo.svelte-xyz { color: green; }
+
+ /* Should match: "bar" is a whole word in class="foo bar" */
+ .bar.svelte-xyz { color: green; }
+
+ /* Should match: "foobar" is the whole class value */
+ .foobar.svelte-xyz { color: green; }
+
+ /* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
+ .bar-foo.svelte-xyz { color: green; }
+
+ /* Should match: "baz" is a whole word in class="bar-foo baz" */
+ .baz.svelte-xyz { color: green; }
+
+ /* Should NOT match: "foob" is not a word in any element's class */
+ /* (unused) .foob { color: red; }*/
+
+ /* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
+ [class~="foo-x"].svelte-xyz { color: green; }
+
+ /* Attribute selector with ~= operator directly */
+ [class~="afoo"].svelte-xyz { color: green; }
+
+ /* === Deep combinator chains (4+ levels) === */
+
+ /* Should match: exact chain main > article > section > div > span */
+ main.svelte-xyz > article:where(.svelte-xyz) > section:where(.svelte-xyz) > div:where(.svelte-xyz) > span:where(.svelte-xyz) { color: green; }
+
+ /* Should match: descendant chain */
+ main.svelte-xyz article:where(.svelte-xyz) section:where(.svelte-xyz) div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
+
+ /* Should match: mixed combinators */
+ main.svelte-xyz > article:where(.svelte-xyz) section:where(.svelte-xyz) > div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
+
+ /* Should NOT match: wrong nesting order */
+ /* (unused) main > article > div > section > span { color: red; }*/
+
+ /* === :has() combined with other selectors === */
+
+ /* Should match: nav.primary has descendant */
+ nav:has(a:where(.svelte-xyz)).primary.svelte-xyz { color: green; }
+
+ /* Should match: nav.secondary has descendant */
+ nav:has(button:where(.svelte-xyz)).secondary.svelte-xyz { color: green; }
+
+ /* Should NOT match: nav.primary doesn't have */
+ /* (unused) nav:has(button).primary { color: red; }*/
+
+ /* Multiple :has() on same element */
+ main.svelte-xyz:has(article:where(.svelte-xyz)):has(span:where(.svelte-xyz)) { color: green; }
+
+ /* :has() with child combinator */
+ main.svelte-xyz:has(> article:where(.svelte-xyz)) { color: green; }
+
+ /* === Escaped selectors === */
+ .a\-b.svelte-xyz { color: green; }
+
+ /* === :is()/:where()/:not() with deep selectors === */
+
+ /* :is() with matching selector */
+ header.svelte-xyz :is(h1:where(.svelte-xyz)) { color: green; }
+
+ /* :where() with matching selector */
+ ul.svelte-xyz :where(li:where(.svelte-xyz)) { color: green; }
+
+ /* :not() ā should match span since it's not a div */
+ span.svelte-xyz:not(div) { color: green; }
+
+ /* :is() with deep combinator */
+ ul.svelte-xyz :is(li:where(.svelte-xyz) > span:where(.svelte-xyz)) { color: green; }
+
+ /* :not() with class ā p.a-b is :not(.unused) */
+ p.svelte-xyz:not(.unused) { color: green; }
+
+ /* Complex: :has() + :is() */
+ ul.svelte-xyz:has(li:where(.svelte-xyz)) :is(span:where(.svelte-xyz)) { color: green; }
diff --git a/packages/svelte/tests/css/samples/css-prune-edge-cases/input.svelte b/packages/svelte/tests/css/samples/css-prune-edge-cases/input.svelte
new file mode 100644
index 0000000000..a17d7255b1
--- /dev/null
+++ b/packages/svelte/tests/css/samples/css-prune-edge-cases/input.svelte
@@ -0,0 +1,125 @@
+
+
+
+word match
+substring only
+hyphen separated
+prefix substring
+
+
+
+
+
+
+
+
+
+
+ link
+
+
+ action
+
+
+
+escaped
+
+
+
+
+
+
From b7bc1309aa72cd942fabcf0aa29b9100c6cc7cf1 Mon Sep 17 00:00:00 2001
From: Philip Breuer
Date: Tue, 3 Mar 2026 16:20:58 +0100
Subject: [PATCH 10/28] fix: preserve original boundary errors when keyed each
rows are removed during async updates (#17843)
Fixes a runtime edge case where keyed #each reconciliation can hit a
missing item during deferred async updates, causing an internal crash
and masking the original boundary error.
Fixes #17841
### 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
- [] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
---------
Co-authored-by: Rich Harris
---
.changeset/odd-badgers-camp.md | 5 ++++
.../src/internal/client/dom/blocks/each.js | 6 +++-
.../_config.js | 28 ++++++++++++++++++
.../main.svelte | 29 +++++++++++++++++++
4 files changed, 67 insertions(+), 1 deletion(-)
create mode 100644 .changeset/odd-badgers-camp.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/main.svelte
diff --git a/.changeset/odd-badgers-camp.md b/.changeset/odd-badgers-camp.md
new file mode 100644
index 0000000000..ada65f561e
--- /dev/null
+++ b/.changeset/odd-badgers-camp.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: preserve original boundary errors when keyed each rows are removed during async updates
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index cac980aa9b..b248ce5544 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -35,7 +35,7 @@ import {
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
-import { BRANCH_EFFECT, COMMENT_NODE, EFFECT_OFFSCREEN, INERT } from '#client/constants';
+import { BRANCH_EFFECT, COMMENT_NODE, DESTROYED, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { queue_micro_task } from '../task.js';
import { get } from '../../runtime.js';
import { DEV } from 'esm-env';
@@ -217,6 +217,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {Batch} batch
*/
function commit(batch) {
+ if ((state.effect.f & DESTROYED) !== 0) {
+ return;
+ }
+
state.pending.delete(batch);
state.fallback = fallback;
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/_config.js
new file mode 100644
index 0000000000..4988e117ed
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/_config.js
@@ -0,0 +1,28 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ // this test doesn't fail without the associated fix ā the error gets
+ // swallowed somewhere. but keeping it around for illustration
+ skip: true,
+
+ mode: ['client'],
+
+ async test({ assert, target, errors, logs }) {
+ const button = target.querySelector('button');
+
+ button?.click();
+ await tick();
+ await tick();
+ assert.deepEqual(logs, ['Simulated TypeError']);
+ assert.deepEqual(errors, []);
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ Trigger
+ Error Caught: Simulated TypeError
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/main.svelte
new file mode 100644
index 0000000000..2d8165a9e3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-error-boundary/main.svelte
@@ -0,0 +1,29 @@
+
+
+ (index = 1)}>Trigger
+
+
+ {#snippet pending()}
+ Loading...
+ {/snippet}
+
+ {#snippet failed(error)}
+ Error Caught: {error.message}
+ {/snippet}
+
+ {#each [[1], [2]][index] as id (id)}
+ {@const result = await fn(id)}
+ {result}
+ {/each}
+
From 25a1c5368be77b1014c3ff8b79cf9b1fe7f706f5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 3 Mar 2026 11:28:35 -0500
Subject: [PATCH 11/28] Version Packages (#17842)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.53.7
### Patch Changes
- fix: correctly add \_\_svelte_meta after else-if chains
([#17830](https://github.com/sveltejs/svelte/pull/17830))
- perf: cache element interactivity and source line splitting in
compiler ([#17839](https://github.com/sveltejs/svelte/pull/17839))
- chore: avoid rescheduling effects during branch commit
([#17837](https://github.com/sveltejs/svelte/pull/17837))
- perf: optimize CSS selector pruning
([#17846](https://github.com/sveltejs/svelte/pull/17846))
- fix: preserve original boundary errors when keyed each rows are
removed during async updates
([#17843](https://github.com/sveltejs/svelte/pull/17843))
- perf: avoid O(n²) name scanning in scope `generate` and `unique`
([#17844](https://github.com/sveltejs/svelte/pull/17844))
- fix: preserve each items that are needed by pending batches
([#17819](https://github.com/sveltejs/svelte/pull/17819))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/breezy-jokes-laugh.md | 5 -----
.changeset/calm-clouds-wave.md | 5 -----
.changeset/curvy-stars-drop.md | 5 -----
.changeset/fast-css-prune.md | 5 -----
.changeset/odd-badgers-camp.md | 5 -----
.changeset/smooth-pens-jump.md | 5 -----
.changeset/spicy-teeth-tan.md | 5 -----
packages/svelte/CHANGELOG.md | 18 ++++++++++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
10 files changed, 20 insertions(+), 37 deletions(-)
delete mode 100644 .changeset/breezy-jokes-laugh.md
delete mode 100644 .changeset/calm-clouds-wave.md
delete mode 100644 .changeset/curvy-stars-drop.md
delete mode 100644 .changeset/fast-css-prune.md
delete mode 100644 .changeset/odd-badgers-camp.md
delete mode 100644 .changeset/smooth-pens-jump.md
delete mode 100644 .changeset/spicy-teeth-tan.md
diff --git a/.changeset/breezy-jokes-laugh.md b/.changeset/breezy-jokes-laugh.md
deleted file mode 100644
index b8ab5b32af..0000000000
--- a/.changeset/breezy-jokes-laugh.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: correctly add \_\_svelte_meta after else-if chains
diff --git a/.changeset/calm-clouds-wave.md b/.changeset/calm-clouds-wave.md
deleted file mode 100644
index 35f13d7ecb..0000000000
--- a/.changeset/calm-clouds-wave.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: cache element interactivity and source line splitting in compiler
diff --git a/.changeset/curvy-stars-drop.md b/.changeset/curvy-stars-drop.md
deleted file mode 100644
index bbbb7a0499..0000000000
--- a/.changeset/curvy-stars-drop.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-chore: avoid rescheduling effects during branch commit
diff --git a/.changeset/fast-css-prune.md b/.changeset/fast-css-prune.md
deleted file mode 100644
index 7b52272f9e..0000000000
--- a/.changeset/fast-css-prune.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: optimize CSS selector pruning
diff --git a/.changeset/odd-badgers-camp.md b/.changeset/odd-badgers-camp.md
deleted file mode 100644
index ada65f561e..0000000000
--- a/.changeset/odd-badgers-camp.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: preserve original boundary errors when keyed each rows are removed during async updates
diff --git a/.changeset/smooth-pens-jump.md b/.changeset/smooth-pens-jump.md
deleted file mode 100644
index 79aea8e6e1..0000000000
--- a/.changeset/smooth-pens-jump.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: avoid O(n²) name scanning in scope `generate` and `unique`
diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md
deleted file mode 100644
index 4497392a9f..0000000000
--- a/.changeset/spicy-teeth-tan.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: preserve each items that are needed by pending batches
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 5f6bd25143..5e1a5cc6f1 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,23 @@
# svelte
+## 5.53.7
+
+### Patch Changes
+
+- fix: correctly add \_\_svelte_meta after else-if chains ([#17830](https://github.com/sveltejs/svelte/pull/17830))
+
+- perf: cache element interactivity and source line splitting in compiler ([#17839](https://github.com/sveltejs/svelte/pull/17839))
+
+- chore: avoid rescheduling effects during branch commit ([#17837](https://github.com/sveltejs/svelte/pull/17837))
+
+- perf: optimize CSS selector pruning ([#17846](https://github.com/sveltejs/svelte/pull/17846))
+
+- fix: preserve original boundary errors when keyed each rows are removed during async updates ([#17843](https://github.com/sveltejs/svelte/pull/17843))
+
+- perf: avoid O(n²) name scanning in scope `generate` and `unique` ([#17844](https://github.com/sveltejs/svelte/pull/17844))
+
+- fix: preserve each items that are needed by pending batches ([#17819](https://github.com/sveltejs/svelte/pull/17819))
+
## 5.53.6
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 5f7035cdd3..5f5b6c3615 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.53.6",
+ "version": "5.53.7",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 677c298064..af92199797 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.53.6';
+export const VERSION = '5.53.7';
export const PUBLIC_VERSION = '5';
From e3f06f9fc7ea0703bc89f94fd82ae4b7855e7a0a Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 4 Mar 2026 17:15:09 -0500
Subject: [PATCH 12/28] fix: skip derived re-evaluation inside inert effect
blocks (#17852)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
this is #17850 with changes (for whatever reason I wasn't able to push
direct to the fork) āĀ same test but simplified, and a simpler fix that
doesn't undo the recent (necessary!) changes to the scheduling logic
---------
Co-authored-by: Mattias Granlund
---
.changeset/chatty-papers-sing.md | 5 +++
.../internal/client/reactivity/deriveds.js | 17 ++++++++--
.../if-block-const-inert-derived/_config.js | 18 +++++++++++
.../if-block-const-inert-derived/main.svelte | 32 +++++++++++++++++++
4 files changed, 70 insertions(+), 2 deletions(-)
create mode 100644 .changeset/chatty-papers-sing.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
diff --git a/.changeset/chatty-papers-sing.md b/.changeset/chatty-papers-sing.md
new file mode 100644
index 0000000000..4d85b91bfc
--- /dev/null
+++ b/.changeset/chatty-papers-sing.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: skip derived re-evaluation inside inert effect blocks
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 7df7651294..2c9b9da33e 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -10,7 +10,8 @@ import {
ASYNC,
WAS_MARKED,
DESTROYED,
- CLEAN
+ CLEAN,
+ INERT
} from '#client/constants';
import {
active_reaction,
@@ -305,10 +306,22 @@ function get_derived_parent_effect(derived) {
* @returns {T}
*/
export function execute_derived(derived) {
+ var parent_effect = get_derived_parent_effect(derived);
+
+ // don't update `{@const ...}` in an outroing block
+ if (
+ !async_mode_flag &&
+ !is_destroying_effect &&
+ parent_effect !== null &&
+ (parent_effect.f & INERT) !== 0
+ ) {
+ return derived.v;
+ }
+
var value;
var prev_active_effect = active_effect;
- set_active_effect(get_derived_parent_effect(derived));
+ set_active_effect(parent_effect);
if (DEV) {
let prev_eager_effects = eager_effects;
diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
new file mode 100644
index 0000000000..b803182079
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
@@ -0,0 +1,18 @@
+import { flushSync, tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: 'clear ',
+
+ async test({ assert, target, raf, logs }) {
+ const [button] = target.querySelectorAll('button');
+
+ flushSync(() => button.click());
+ assert.deepEqual(logs, ['hello']);
+
+ // Let the transition finish and clean up
+ raf.tick(100);
+
+ assert.htmlEqual(target.innerHTML, 'clear ');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
new file mode 100644
index 0000000000..58b34d52b9
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
@@ -0,0 +1,32 @@
+
+
+ {
+ value = undefined;
+ }}>clear
+
+{#if value}
+ {@const result = compute(value)}
+ {#if result.ready}
+
+ {/if}
+{/if}
From 61a443f1fafb8c7f3da7f4839dc5c787485c91ae Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 5 Mar 2026 07:00:45 -0500
Subject: [PATCH 13/28] chore(deps): bump immutable from 4.3.7 to 4.3.8
(#17860)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [immutable](https://github.com/immutable-js/immutable-js) from
4.3.7 to 4.3.8.
Release notes
Sourced from immutable's
releases .
v4.3.8
Fix Improperly Controlled Modification of Object Prototype Attributes
('Prototype Pollution') in immutable
Changelog
Sourced from immutable's
changelog .
Changelog
All notable changes to this project will be documented in this
file.
The format is based on Keep a Changelog ,
and this project adheres to Semantic Versioning .
Dates are formatted as YYYY-MM-DD.
Unreleased
5.1.5
Fix Improperly Controlled Modification of Object Prototype
Attributes ('Prototype Pollution') in immutable
5.1.4
Documentation
Internal
5.1.3
TypeScript
Documentation
There has been a huge amount of changes in the documentation, mainly
migrate from an autogenerated documentation from .d.ts file, to a proper
documentation in markdown.
The playground has been included on nearly all method examples.
We added a page about browser extensions too: https://immutable-js.com/browser-extension/
Internal
... (truncated)
Commits
Maintainer changes
This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub
Actions), a new releaser for immutable since your current version.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/sveltejs/svelte/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pnpm-lock.yaml | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5d857ee81..8693c466c1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1293,8 +1293,8 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- enhanced-resolve@5.19.0:
- resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
+ enhanced-resolve@5.20.0:
+ resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
@@ -1604,8 +1604,8 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
- immutable@4.3.7:
- resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
+ immutable@4.3.8:
+ resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -3589,7 +3589,7 @@ snapshots:
emoji-regex@9.2.2: {}
- enhanced-resolve@5.19.0:
+ enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@@ -3685,7 +3685,7 @@ snapshots:
eslint-plugin-n@17.24.0(eslint@10.0.0)(typescript@5.5.4):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0)
- enhanced-resolve: 5.19.0
+ enhanced-resolve: 5.20.0
eslint: 10.0.0
eslint-plugin-es-x: 7.8.0(eslint@10.0.0)
get-tsconfig: 4.13.6
@@ -3974,7 +3974,7 @@ snapshots:
ignore@7.0.5: {}
- immutable@4.3.7:
+ immutable@4.3.8:
optional: true
imurmurhash@0.1.4: {}
@@ -4450,7 +4450,7 @@ snapshots:
sass@1.70.0:
dependencies:
chokidar: 3.6.0
- immutable: 4.3.7
+ immutable: 4.3.8
source-map-js: 1.2.1
optional: true
From 9066b75c01f38c9a0966c9ca4835b030e74bea1d Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 5 Mar 2026 07:07:33 -0500
Subject: [PATCH 14/28] chore: tidy up (#17863)
small tweaks, will self-merge
---
packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 +------
packages/svelte/src/internal/client/reactivity/async.js | 1 -
packages/svelte/src/internal/client/reactivity/batch.js | 1 -
3 files changed, 1 insertion(+), 8 deletions(-)
diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js
index 429a2eb293..052736c35e 100644
--- a/packages/svelte/src/internal/client/dom/blocks/boundary.js
+++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js
@@ -225,7 +225,6 @@ export class Boundary {
fragment.append(anchor);
this.#main_effect = this.#run(() => {
- Batch.ensure();
return branch(() => this.#children(anchor));
});
@@ -320,6 +319,7 @@ export class Boundary {
set_component_context(this.#effect.ctx);
try {
+ Batch.ensure();
return fn();
} catch (e) {
handle_error(e);
@@ -445,9 +445,6 @@ export class Boundary {
}
this.#run(() => {
- // If the failure happened while flushing effects, current_batch can be null
- Batch.ensure();
-
this.#render();
});
};
@@ -464,8 +461,6 @@ export class Boundary {
if (failed) {
this.#failed_effect = this.#run(() => {
- Batch.ensure();
-
try {
return branch(() => {
// errors in `failed` snippets cause the boundary to error again
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index f2643e0c34..edd2b37371 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -43,7 +43,6 @@ export function flatten(blockers, sync, async, fn) {
return;
}
- var batch = current_batch;
var parent = /** @type {Effect} */ (active_effect);
var restore = capture();
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 73e4a30fa4..a1cd08bd6a 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -14,7 +14,6 @@ import {
MAYBE_DIRTY,
DERIVED,
EAGER_EFFECT,
- HEAD_EFFECT,
ERROR_VALUE,
MANAGED_EFFECT,
REACTION_RAN
From aed36051fdddf2be34242c372b1a3dfb3a9e653b Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 5 Mar 2026 10:30:26 -0500
Subject: [PATCH 15/28] chore: robustify `flatten` (#17864)
Extracted from #17805. Currently we restore context in`flatten`
unnecessarily in the case where we have async expressions but no
blockers (the context is already correct), and we don't unset context
after blockers resolve in the case where we have them. The first bit is
suboptimal, but the second bit feels bug-shaped, even though I'm not
currently aware of any actual bugs that have resulted from this.
### Before submitting the PR, please make sure you do the following
- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [ ] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).
### Tests and linting
- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
---
packages/svelte/src/internal/client/reactivity/async.js | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index edd2b37371..093d45ec0a 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -76,14 +76,17 @@ export function flatten(blockers, sync, async, fn) {
// Full path: has async expressions
function run() {
- restore();
Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => finish([...sync.map(d), ...result]))
.catch((error) => invoke_error_boundary(error, parent));
}
if (blocker_promise) {
- blocker_promise.then(run);
+ blocker_promise.then(() => {
+ restore();
+ run();
+ unset_context();
+ });
} else {
run();
}
From 7dc864d94160164ae9f835b51aed24f3e4c6f539 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 5 Mar 2026 21:34:38 -0500
Subject: [PATCH 16/28] Revert "fix: skip derived re-evaluation inside inert
effect blocks" (#17869)
Reverts sveltejs/svelte#17852, because it isn't a real fix
https://github.com/sveltejs/svelte/pull/17868
---
.changeset/chatty-papers-sing.md | 5 ---
.../internal/client/reactivity/deriveds.js | 17 ++--------
.../if-block-const-inert-derived/_config.js | 18 -----------
.../if-block-const-inert-derived/main.svelte | 32 -------------------
4 files changed, 2 insertions(+), 70 deletions(-)
delete mode 100644 .changeset/chatty-papers-sing.md
delete mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
delete mode 100644 packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
diff --git a/.changeset/chatty-papers-sing.md b/.changeset/chatty-papers-sing.md
deleted file mode 100644
index 4d85b91bfc..0000000000
--- a/.changeset/chatty-papers-sing.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: skip derived re-evaluation inside inert effect blocks
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 2c9b9da33e..7df7651294 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -10,8 +10,7 @@ import {
ASYNC,
WAS_MARKED,
DESTROYED,
- CLEAN,
- INERT
+ CLEAN
} from '#client/constants';
import {
active_reaction,
@@ -306,22 +305,10 @@ function get_derived_parent_effect(derived) {
* @returns {T}
*/
export function execute_derived(derived) {
- var parent_effect = get_derived_parent_effect(derived);
-
- // don't update `{@const ...}` in an outroing block
- if (
- !async_mode_flag &&
- !is_destroying_effect &&
- parent_effect !== null &&
- (parent_effect.f & INERT) !== 0
- ) {
- return derived.v;
- }
-
var value;
var prev_active_effect = active_effect;
- set_active_effect(parent_effect);
+ set_active_effect(get_derived_parent_effect(derived));
if (DEV) {
let prev_eager_effects = eager_effects;
diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
deleted file mode 100644
index b803182079..0000000000
--- a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/_config.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { flushSync, tick } from 'svelte';
-import { test } from '../../test';
-
-export default test({
- html: 'clear ',
-
- async test({ assert, target, raf, logs }) {
- const [button] = target.querySelectorAll('button');
-
- flushSync(() => button.click());
- assert.deepEqual(logs, ['hello']);
-
- // Let the transition finish and clean up
- raf.tick(100);
-
- assert.htmlEqual(target.innerHTML, 'clear ');
- }
-});
diff --git a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
deleted file mode 100644
index 58b34d52b9..0000000000
--- a/packages/svelte/tests/runtime-runes/samples/if-block-const-inert-derived/main.svelte
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
- {
- value = undefined;
- }}>clear
-
-{#if value}
- {@const result = compute(value)}
- {#if result.ready}
-
- {/if}
-{/if}
From 2a1f5ada13e167ed82e44274ea45722bc640900b Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Thu, 5 Mar 2026 21:36:03 -0500
Subject: [PATCH 17/28] perf: avoid re-traversing the effect tree after `$:`
assignments (#17848)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
If an assignment happens in a `$:` statement, any affected effects are
rescheduled while the traversal is ongoing. But this is wasteful ā it
results in the `flush_effects` loop running another time, even though
the affected effects are guaranteed to be visited _later_ in the
traversal (unless the thing being updated is a store).
This PR fixes it: inside a `legacy_pre_effect`, we temporarily pretend
that the branch _containing_ the component with the `$:` statement is
the `active_effect`, such that Svelte understands that any marked
effects are about to be visited and thus don't need to be scheduled. We
deal with the store case by temporarily pretending that there _is_ no
`active_effect`.
I will be delighted when we can rip all this legacy stuff out of the
codebase.
### Before submitting the PR, please make sure you do the following
- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [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/ninety-kings-attend.md | 5 ++++
.../src/internal/client/reactivity/batch.js | 14 +++++++--
.../src/internal/client/reactivity/effects.js | 17 +++++++++--
.../src/internal/client/reactivity/store.js | 29 ++++++++++++++++---
.../samples/store-reschedule/Child.svelte | 9 ++++++
.../samples/store-reschedule/_config.js | 22 ++++++++++++++
.../samples/store-reschedule/main.svelte | 6 ++++
.../samples/store-reschedule/stores.js | 3 ++
8 files changed, 97 insertions(+), 8 deletions(-)
create mode 100644 .changeset/ninety-kings-attend.md
create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte
create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js
create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte
create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js
diff --git a/.changeset/ninety-kings-attend.md b/.changeset/ninety-kings-attend.md
new file mode 100644
index 0000000000..40913dab67
--- /dev/null
+++ b/.changeset/ninety-kings-attend.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+perf: avoid re-traversing the effect tree after `$:` assignments
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index a1cd08bd6a..638aba2fcd 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -22,6 +22,7 @@ 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,
is_dirty,
@@ -36,6 +37,7 @@ import { eager_effect, 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';
/** @type {Set} */
const batches = new Set();
@@ -856,10 +858,18 @@ export function schedule_effect(signal) {
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && effect === 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
- if (async_mode_flag || (signal.f & RENDER_EFFECT) === 0) {
+ // 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;
}
}
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index b3d37659ea..3118851277 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -10,7 +10,8 @@ import {
set_active_reaction,
set_is_destroying_effect,
untrack,
- untracking
+ untracking,
+ set_active_effect
} from '../runtime.js';
import {
DIRTY,
@@ -316,7 +317,19 @@ export function legacy_pre_effect(deps, fn) {
if (token.ran) return;
token.ran = true;
- untrack(fn);
+
+ var effect = /** @type {Effect} */ (active_effect);
+
+ // here, we lie: by setting `active_effect` to be the parent branch, any writes
+ // that happen inside `fn` will _not_ cause an unnecessary reschedule, because
+ // the affected effects will be children of `active_effect`. this is safe
+ // because these effects are known to run in the correct order
+ try {
+ set_active_effect(effect.parent);
+ untrack(fn);
+ } finally {
+ set_active_effect(effect);
+ }
});
}
diff --git a/packages/svelte/src/internal/client/reactivity/store.js b/packages/svelte/src/internal/client/reactivity/store.js
index ce082866ce..7124e23db8 100644
--- a/packages/svelte/src/internal/client/reactivity/store.js
+++ b/packages/svelte/src/internal/client/reactivity/store.js
@@ -8,6 +8,12 @@ import { teardown } from './effects.js';
import { mutable_source, set } from './sources.js';
import { DEV } from 'esm-env';
+/**
+ * We set this to `true` when updating a store so that we correctly
+ * schedule effects if the update takes place inside a `$:` effect
+ */
+export let legacy_is_updating_store = false;
+
/**
* Whether or not the prop currently being read is a store binding, as in
* ` `. If it is, we treat the prop as mutable even in
@@ -102,7 +108,7 @@ export function store_unsub(store, store_name, stores) {
* @returns {V}
*/
export function store_set(store, value) {
- store.set(value);
+ update_with_flag(store, value);
return value;
}
@@ -141,6 +147,21 @@ export function setup_stores() {
return [stores, cleanup];
}
+/**
+ * @param {Store} store
+ * @param {V} value
+ * @template V
+ */
+function update_with_flag(store, value) {
+ legacy_is_updating_store = true;
+
+ try {
+ store.set(value);
+ } finally {
+ legacy_is_updating_store = false;
+ }
+}
+
/**
* Updates a store with a new value.
* @param {Store} store the store to update
@@ -149,7 +170,7 @@ export function setup_stores() {
* @template V
*/
export function store_mutate(store, expression, new_value) {
- store.set(new_value);
+ update_with_flag(store, new_value);
return expression;
}
@@ -160,7 +181,7 @@ export function store_mutate(store, expression, new_value) {
* @returns {number}
*/
export function update_store(store, store_value, d = 1) {
- store.set(store_value + d);
+ update_with_flag(store, store_value + d);
return store_value;
}
@@ -172,7 +193,7 @@ export function update_store(store, store_value, d = 1) {
*/
export function update_pre_store(store, store_value, d = 1) {
const value = store_value + d;
- store.set(value);
+ update_with_flag(store, value);
return value;
}
diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte
new file mode 100644
index 0000000000..d955a82a88
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/Child.svelte
@@ -0,0 +1,9 @@
+
+
+ n += 1}>{$count}
diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js
new file mode 100644
index 0000000000..1c9ea0d5ea
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/_config.js
@@ -0,0 +1,22 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: `0 0 `,
+
+ test({ assert, target }) {
+ const [button1, button2] = target.querySelectorAll('button');
+
+ flushSync(() => button1.click());
+ assert.htmlEqual(target.innerHTML, `1 1 `);
+
+ flushSync(() => button1.click());
+ assert.htmlEqual(target.innerHTML, `2 2 `);
+
+ flushSync(() => button2.click());
+ assert.htmlEqual(target.innerHTML, `1 1 `);
+
+ flushSync(() => button2.click());
+ assert.htmlEqual(target.innerHTML, `2 2 `);
+ }
+});
diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte
new file mode 100644
index 0000000000..55c1438411
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/main.svelte
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js
new file mode 100644
index 0000000000..d432d339ec
--- /dev/null
+++ b/packages/svelte/tests/runtime-legacy/samples/store-reschedule/stores.js
@@ -0,0 +1,3 @@
+import { writable } from 'svelte/store';
+
+export const count = writable(0);
From 3df2645451e6e9441e02d423d2fe77b8e4a38f59 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Fri, 6 Mar 2026 11:27:47 -0500
Subject: [PATCH 18/28] chore: deactivate batch after async derived resolves
(#17865)
Extracted from #17805. Similar to #17864, I'm not aware of any bugs
resulting from this, but the fact that we're setting `current_batch`
before calling `internal_set` and then not _unsetting_ `current_batch`
feels like something that could potentially bite us.
---
packages/svelte/src/internal/client/reactivity/deriveds.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 7df7651294..c1ee4f3f52 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -196,6 +196,8 @@ export function async_derived(fn, label, location) {
if (decrement_pending) {
decrement_pending();
}
+
+ batch.deactivate();
};
d.promise.then(handler, (e) => handler(null, e || 'unknown'));
From 0206a2019ec55ab62e8dbfd4449e371e9d76eb5c Mon Sep 17 00:00:00 2001
From: dev-miro26 <121471669+dev-miro26@users.noreply.github.com>
Date: Fri, 6 Mar 2026 18:11:16 -0600
Subject: [PATCH 19/28] fix: clean up externally-added DOM nodes in {@html} on
re-render (#17853)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes `{@html}` content duplication when used inside a contenteditable
element.
When `{@html content}` is inside a contenteditable element and the user
types, the browser inserts DOM nodes directly into the {@html} managed
region. On re-render (e.g. triggered by a blur handler setting `content
= e.currentTarget.innerText`, the `{@html} `block only removed nodes it
previously created via` effect.nodes`, leaving browser-inserted nodes in
place. This caused content to appear twice ā once as leftover text nodes
and once as the new `{@html}` output.
The fix tracks the boundary node (`previousSibling `of the anchor at
init) and removes all nodes between the boundary and the anchor on
re-render, ensuring externally-added nodes are also cleaned up.
Closes: #16993
---------
Co-authored-by: 7nik
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
---
.changeset/html-contenteditable-fix.md | 5 +++
.../3-transform/client/visitors/HtmlTag.js | 13 +++++--
.../client/visitors/shared/fragment.js | 2 ++
.../svelte/src/compiler/types/template.d.ts | 2 ++
.../src/internal/client/dom/blocks/html.js | 34 ++++++++++++++++++-
.../html-tag-contenteditable/_config.js | 25 ++++++++++++++
.../html-tag-contenteditable/main.svelte | 9 +++++
7 files changed, 86 insertions(+), 4 deletions(-)
create mode 100644 .changeset/html-contenteditable-fix.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte
diff --git a/.changeset/html-contenteditable-fix.md b/.changeset/html-contenteditable-fix.md
new file mode 100644
index 0000000000..5cae7f6234
--- /dev/null
+++ b/.changeset/html-contenteditable-fix.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: `{@html}` no longer duplicates content inside `contenteditable` elements
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
index 2706cf7f0a..6c8b7c0354 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
@@ -9,7 +9,11 @@ import { build_expression } from './shared/utils.js';
* @param {ComponentContext} context
*/
export function HtmlTag(node, context) {
- context.state.template.push_comment();
+ const is_controlled = node.metadata.is_controlled;
+
+ if (!is_controlled) {
+ context.state.template.push_comment();
+ }
const has_await = node.metadata.expression.has_await;
const has_blockers = node.metadata.expression.has_blockers();
@@ -17,14 +21,17 @@ export function HtmlTag(node, context) {
const expression = build_expression(context, node.expression, node.metadata.expression);
const html = has_await ? b.call('$.get', b.id('$$html')) : expression;
- const is_svg = context.state.metadata.namespace === 'svg';
- const is_mathml = context.state.metadata.namespace === 'mathml';
+ // When is_controlled, the parent node already provides the correct namespace,
+ // so is_svg/is_mathml are only needed for the non-controlled path's wrapper element
+ const is_svg = !is_controlled && context.state.metadata.namespace === 'svg';
+ const is_mathml = !is_controlled && context.state.metadata.namespace === 'mathml';
const statement = b.stmt(
b.call(
'$.html',
context.state.node,
b.thunk(html),
+ is_controlled && b.true,
is_svg && b.true,
is_mathml && b.true,
is_ignored(node, 'hydration_html_changed') && b.true
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
index 59b93f24ef..bd3e708662 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
@@ -109,6 +109,8 @@ export function process_children(nodes, initial, is_element, context) {
!node.metadata.expression.is_async()
) {
node.metadata.is_controlled = true;
+ } else if (node.type === 'HtmlTag' && nodes.length === 1 && is_element) {
+ node.metadata.is_controlled = true;
} else {
const id = flush_node(
false,
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index d44a31349a..3c1e3e772c 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -133,6 +133,8 @@ export namespace AST {
/** @internal */
metadata: {
expression: ExpressionMetadata;
+ /** If `true`, the `{@html}` block is the only child of its parent element and can use `parent.innerHTML` directly */
+ is_controlled?: boolean;
};
}
diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js
index af66a04534..ffe947eb16 100644
--- a/packages/svelte/src/internal/client/dom/blocks/html.js
+++ b/packages/svelte/src/internal/client/dom/blocks/html.js
@@ -42,17 +42,33 @@ function check_hash(element, server_hash, value) {
/**
* @param {Element | Text | Comment} node
* @param {() => string | TrustedHTML} get_value
+ * @param {boolean} [is_controlled]
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
* @returns {void}
*/
-export function html(node, get_value, svg = false, mathml = false, skip_warning = false) {
+export function html(
+ node,
+ get_value,
+ is_controlled = false,
+ svg = false,
+ mathml = false,
+ skip_warning = false
+) {
var anchor = node;
/** @type {string | TrustedHTML} */
var value = '';
+ if (is_controlled) {
+ var parent_node = /** @type {Element} */ (node);
+
+ if (hydrating) {
+ anchor = set_hydrate_node(get_first_child(parent_node));
+ }
+ }
+
template_effect(() => {
var effect = /** @type {Effect} */ (active_effect);
@@ -61,6 +77,22 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
return;
}
+ if (is_controlled && !hydrating) {
+ // When @html is the only child, use innerHTML directly.
+ // This also handles contenteditable, where the user may delete the anchor comment.
+ effect.nodes = null;
+ parent_node.innerHTML = /** @type {string} */ (value);
+
+ if (value !== '') {
+ assign_nodes(
+ /** @type {TemplateNode} */ (get_first_child(parent_node)),
+ /** @type {TemplateNode} */ (parent_node.lastChild)
+ );
+ }
+
+ return;
+ }
+
if (effect.nodes !== null) {
remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end));
effect.nodes = null;
diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js
new file mode 100644
index 0000000000..9e188b1119
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js
@@ -0,0 +1,25 @@
+import { flushSync } from '../../../../src/index-client';
+import { test } from '../../test';
+
+export default test({
+ html: `
`,
+
+ test({ assert, target }) {
+ const div = /** @type {HTMLDivElement} */ (target.querySelector('#editable'));
+ const output = /** @type {HTMLParagraphElement} */ (target.querySelector('#output'));
+
+ // Simulate user typing by directly modifying the DOM
+ div.textContent = 'hello';
+
+ // Simulate blur which triggers `content = e.currentTarget.innerText`
+ const event = new Event('blur');
+ div.dispatchEvent(event);
+ flushSync();
+
+ // The output should show "hello" (innerText was set correctly)
+ assert.equal(output.textContent, 'hello');
+
+ // The contenteditable div should contain "hello" once, not duplicated
+ assert.htmlEqual(div.innerHTML, 'hello');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte
new file mode 100644
index 0000000000..3f887f2a9a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte
@@ -0,0 +1,9 @@
+
+
+ { content = e.currentTarget.textContent; }} contenteditable="true">
+ {@html content}
+
+
+{content}
From 2deebdea8ffbdb74790ce7021e3b6992b39b77bb Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Sat, 7 Mar 2026 01:59:58 +0100
Subject: [PATCH 20/28] fix: handle asnyc updates within pending boundary
(#17873)
When an async value is updated inside the boundary while the pending
snippet is shown, we previously didn't notice that update and instead
showed an outdated value once it resolved. This fixes that by rejecting
all deferreds inside an async_derived while the pending snippet is
shown.
---------
Co-authored-by: Rich Harris
---
.changeset/nasty-friends-crash.md | 5 ++
.../internal/client/reactivity/deriveds.js | 14 ++++-
.../_config.js | 51 +++++++++++++++++++
.../main.svelte | 19 +++++++
4 files changed, 87 insertions(+), 2 deletions(-)
create mode 100644 .changeset/nasty-friends-crash.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte
diff --git a/.changeset/nasty-friends-crash.md b/.changeset/nasty-friends-crash.md
new file mode 100644
index 0000000000..5895f3752a
--- /dev/null
+++ b/.changeset/nasty-friends-crash.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: handle asnyc updates within pending boundary
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index c1ee4f3f52..d8989ef03d 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -146,8 +146,18 @@ export function async_derived(fn, label, location) {
if (should_suspend) {
var decrement_pending = increment_pending();
- deferreds.get(batch)?.reject(STALE_REACTION);
- deferreds.delete(batch); // delete to ensure correct order in Map iteration below
+ 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
+ } 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);
+ }
+ deferreds.clear();
+ }
+
deferreds.set(batch, d);
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js
new file mode 100644
index 0000000000..e444aa8f9b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/_config.js
@@ -0,0 +1,51 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [shift, increment] = target.querySelectorAll('button');
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ shift
+ increment
+ loading
+ `
+ );
+
+ increment.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ shift
+ increment
+ loading
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ shift
+ increment
+ loading
+ `
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ shift
+ increment
+ 1
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte
new file mode 100644
index 0000000000..c5a32dc4b9
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-update-while-pending/main.svelte
@@ -0,0 +1,19 @@
+
+
+ queue.shift()()}>shift
+ count++}>increment
+
+
+ {await push(count)}
+ {#snippet pending()}loading{/snippet}
+
From 6fb7b4d265c1ffc2ff48fdf89be2244f22c6bb05 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Sun, 8 Mar 2026 07:39:07 -0400
Subject: [PATCH 21/28] chore: refactor scheduling (#17805)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This simplifies the scheduling logic and will likely improve performance
in some cases. Previously, there was a global `queued_root_effects`
array, and we would cycle through the batch flushing logic as long as it
was non-empty. This was a very loosey-goosey approach that was
appropriate in the pre-async world, but has gradually become a source of
confusion.
Now, effects are scheduled within the context of a specific batch. The
lifecycle is more rigorous and debuggable. This opens the door to
explorations of alternative approaches, such as only scheduling effects
when we call `batch.flush()`, which _may_ be better than the eager
status quo.
The layout of the `Batch` class is extremely chaotic ā
public/private/static fields/methods are all jumbled up together ā and I
would like to get a grip of it. In the interests of minimising diff
noise that ought to be a follow-up rather than part of this PR.
### Before submitting the PR, please make sure you do the following
- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [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>
---
.changeset/slick-bars-train.md | 5 +
.../src/internal/client/dom/blocks/await.js | 7 +
.../internal/client/dom/blocks/boundary.js | 31 +-
.../client/dom/elements/bindings/input.js | 6 +-
.../client/dom/elements/bindings/select.js | 6 +-
.../src/internal/client/reactivity/async.js | 30 +-
.../src/internal/client/reactivity/batch.js | 419 +++++++++---------
.../internal/client/reactivity/deriveds.js | 36 +-
.../src/internal/client/reactivity/effects.js | 4 +-
.../src/internal/client/reactivity/props.js | 4 +-
.../src/internal/client/reactivity/sources.js | 30 +-
.../async-attribute-without-state/_config.js | 9 +-
12 files changed, 319 insertions(+), 268 deletions(-)
create mode 100644 .changeset/slick-bars-train.md
diff --git a/.changeset/slick-bars-train.md b/.changeset/slick-bars-train.md
new file mode 100644
index 0000000000..795f8d806f
--- /dev/null
+++ b/.changeset/slick-bars-train.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+chore: simplify scheduling logic
diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js
index 09a3ec5ca4..d6430547b5 100644
--- a/packages/svelte/src/internal/client/dom/blocks/await.js
+++ b/packages/svelte/src/internal/client/dom/blocks/await.js
@@ -45,7 +45,14 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var branches = new BranchManager(node);
block(() => {
+ var batch = /** @type {Batch} */ (current_batch);
+
+ // we null out `current_batch` because otherwise `save(...)` will incorrectly restore it ā
+ // the batch will already have been committed by the time it resolves
+ batch.deactivate();
var input = get_input();
+ batch.activate();
+
var destroyed = false;
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js
index 052736c35e..b38a3131ca 100644
--- a/packages/svelte/src/internal/client/dom/blocks/boundary.js
+++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js
@@ -35,7 +35,7 @@ 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, schedule_effect } from '../../reactivity/batch.js';
+import { Batch, current_batch, schedule_effect } 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';
@@ -218,6 +218,8 @@ export class Boundary {
this.is_pending = true;
this.#pending_effect = branch(() => pending(this.#anchor));
+ var batch = /** @type {Batch} */ (current_batch);
+
queue_micro_task(() => {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var anchor = create_text();
@@ -236,12 +238,14 @@ export class Boundary {
this.#pending_effect = null;
});
- this.#resolve();
+ this.#resolve(batch);
}
});
}
#render() {
+ var batch = /** @type {Batch} */ (current_batch);
+
try {
this.is_pending = this.has_pending_snippet();
this.#pending_count = 0;
@@ -258,14 +262,17 @@ export class Boundary {
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
this.#pending_effect = branch(() => pending(this.#anchor));
} else {
- this.#resolve();
+ this.#resolve(batch);
}
} catch (error) {
this.error(error);
}
}
- #resolve() {
+ /**
+ * @param {Batch} batch
+ */
+ #resolve(batch) {
this.is_pending = false;
// any effects that were previously deferred should be rescheduled ā
@@ -273,12 +280,12 @@ export class Boundary {
// same update that brought us here) the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
- schedule_effect(e);
+ batch.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
- schedule_effect(e);
+ batch.schedule(e);
}
this.#dirty_effects.clear();
@@ -335,11 +342,12 @@ export class Boundary {
* Updates the pending count associated with the currently visible pending snippet,
* if any, such that we can replace the snippet with content once work is done
* @param {1 | -1} d
+ * @param {Batch} batch
*/
- #update_pending_count(d) {
+ #update_pending_count(d, batch) {
if (!this.has_pending_snippet()) {
if (this.parent) {
- this.parent.#update_pending_count(d);
+ this.parent.#update_pending_count(d, batch);
}
// if there's no parent, we're in a scope with no pending snippet
@@ -349,7 +357,7 @@ export class Boundary {
this.#pending_count += d;
if (this.#pending_count === 0) {
- this.#resolve();
+ this.#resolve(batch);
if (this.#pending_effect) {
pause_effect(this.#pending_effect, () => {
@@ -369,9 +377,10 @@ export class Boundary {
* and controls when the current `pending` snippet (if any) is removed.
* Do not call from inside the class
* @param {1 | -1} d
+ * @param {Batch} batch
*/
- update_pending_count(d) {
- this.#update_pending_count(d);
+ update_pending_count(d, batch) {
+ this.#update_pending_count(d, batch);
this.#local_pending_count += d;
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
index 23ad6f5cdc..55e61c3774 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
@@ -9,6 +9,7 @@ import { hydrating } from '../../hydration.js';
import { tick, untrack } from '../../../runtime.js';
import { is_runes } from '../../../context.js';
import { current_batch, previous_batch } from '../../../reactivity/batch.js';
+import { async_mode_flag } from '../../../../flags/index.js';
/**
* @param {HTMLInputElement} input
@@ -87,8 +88,9 @@ export function bind_value(input, get, set = get) {
var value = get();
if (input === document.activeElement) {
- // we need both, because in non-async mode, render effects run before previous_batch is set
- var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
+ // In sync mode render effects are executed during tree traversal -> needs current_batch
+ // In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
+ var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Never rewrite the contents of a focused input. We can get here if, for example,
// an update is deferred because of async work depending on the input:
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
index 46e8f524f8..21be75ba61 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
@@ -4,6 +4,7 @@ import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
+import { async_mode_flag } from '../../../../flags/index.js';
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
@@ -115,8 +116,9 @@ export function bind_select_value(select, get, set = get) {
var value = get();
if (select === document.activeElement) {
- // we need both, because in non-async mode, render effects run before previous_batch is set
- var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
+ // In sync mode render effects are executed during tree traversal -> needs current_batch
+ // In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
+ var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Don't update the if it is focused. We can get here if, for example,
// an update is deferred because of async work depending on the select:
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 093d45ec0a..f8f5fec290 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -74,11 +74,14 @@ export function flatten(blockers, sync, async, fn) {
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));
+ .catch((error) => invoke_error_boundary(error, parent))
+ .finally(() => decrement_pending());
}
if (blocker_promise) {
@@ -106,10 +109,10 @@ export function run_after_blockers(blockers, fn) {
* causes `b` to be registered as a dependency).
*/
export function capture() {
- var previous_effect = active_effect;
+ var previous_effect = /** @type {Effect} */ (active_effect);
var previous_reaction = active_reaction;
var previous_component_context = component_context;
- var previous_batch = current_batch;
+ var previous_batch = /** @type {Batch} */ (current_batch);
if (DEV) {
var previous_dev_stack = dev_stack;
@@ -119,7 +122,13 @@ export function capture() {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_component_context);
- if (activate_batch) previous_batch?.activate();
+
+ if (activate_batch && (previous_effect.f & DESTROYED) === 0) {
+ // TODO we only need optional chaining here because `{#await ...}` blocks
+ // are anomalous. Once we retire them we can get rid of it
+ previous_batch?.activate();
+ previous_batch?.apply();
+ }
if (DEV) {
set_from_async_derived(null);
@@ -282,7 +291,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;
}
@@ -294,16 +303,19 @@ export function wait(blockers) {
return Promise.all(blockers.map((b) => b.promise));
}
+/**
+ * @returns {(skip?: boolean) => void}
+ */
export function increment_pending() {
var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
- boundary.update_pending_count(1);
+ boundary.update_pending_count(1, batch);
batch.increment(blocking);
- return () => {
- boundary.update_pending_count(-1);
- batch.decrement(blocking);
+ return (skip = false) => {
+ boundary.update_pending_count(-1, batch);
+ batch.decrement(blocking, skip);
};
}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 638aba2fcd..e6dcc95510 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -46,8 +46,7 @@ const batches = new Set();
export let current_batch = null;
/**
- * This is needed to avoid overwriting inputs in non-async mode
- * TODO 6.0 remove this, as non-async mode will go away
+ * This is needed to avoid overwriting inputs
* @type {Batch | null}
*/
export let previous_batch = null;
@@ -60,14 +59,11 @@ export let previous_batch = null;
*/
export let batch_values = null;
-// TODO this should really be a property of `batch`
-/** @type {Effect[]} */
-let queued_root_effects = [];
-
/** @type {Effect | null} */
let last_scheduled_effect = null;
export let is_flushing_sync = false;
+let is_processing = false;
/**
* During traversal, this is an array. Newly created effects are (if not immediately
@@ -77,6 +73,18 @@ export let is_flushing_sync = false;
*/
export let collected_effects = null;
+/**
+ * An array of effects that are marked during traversal as a result of a `set`
+ * (not `internal_set`) call. These will be added to the next batch and
+ * trigger another `batch.process()`
+ * @type {Effect[] | null}
+ * @deprecated when we get rid of legacy mode and stores, we can get rid of this
+ */
+export let legacy_updates = null;
+
+var flush_count = 0;
+var source_stacks = DEV ? new Set() : null;
+
let uid = 1;
export class Batch {
@@ -127,6 +135,12 @@ export class Batch {
*/
#deferred = null;
+ /**
+ * The root effects that need to be flushed
+ * @type {Effect[]}
+ */
+ #roots = [];
+
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set}
@@ -178,22 +192,23 @@ export class Batch {
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
- schedule_effect(e);
+ this.schedule(e);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
- schedule_effect(e);
+ this.schedule(e);
}
}
}
- /**
- *
- * @param {Effect[]} root_effects
- */
- process(root_effects) {
- queued_root_effects = [];
+ #process() {
+ if (flush_count++ > 1000) {
+ infinite_loop_guard();
+ }
+
+ const roots = this.#roots;
+ this.#roots = [];
this.apply();
@@ -203,16 +218,28 @@ export class Batch {
/** @type {Effect[]} */
var render_effects = [];
- for (const root of root_effects) {
- this.#traverse_effect_tree(root, effects, render_effects);
- // Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects,
- // which means queued_root_effects now may be filled again.
+ /**
+ * @type {Effect[]}
+ * @deprecated when we get rid of legacy mode and stores, we can get rid of this
+ */
+ var updates = (legacy_updates = []);
+
+ for (const root of roots) {
+ this.#traverse(root, effects, render_effects);
+ }
+
+ // any writes should take effect in a subsequent batch
+ current_batch = null;
- // Helpful for debugging reactivity loss that has to do with branches being skipped:
- // log_inconsistent_branches(root);
+ if (updates.length > 0) {
+ var batch = Batch.ensure();
+ for (const e of updates) {
+ batch.schedule(e);
+ }
}
collected_effects = null;
+ legacy_updates = null;
if (this.#is_deferred()) {
this.#defer_effects(render_effects);
@@ -222,32 +249,39 @@ export class Batch {
reset_branch(e, t);
}
} else {
- // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
- // newly updated sources, which could lead to infinite loops when effects run over and over again.
- previous_batch = this;
- current_batch = null;
+ // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
+ this.#dirty_effects.clear();
+ this.#maybe_dirty_effects.clear();
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
+ previous_batch = this;
+ flush_queued_effects(render_effects);
+ flush_queued_effects(effects);
+ previous_batch = null;
+
if (this.#pending === 0) {
this.#commit();
}
- flush_queued_effects(render_effects);
- flush_queued_effects(effects);
+ this.#deferred?.resolve();
+ }
- // Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
- this.#dirty_effects.clear();
- this.#maybe_dirty_effects.clear();
+ var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
- previous_batch = null;
+ if (next_batch !== null) {
+ batches.add(next_batch);
- this.#deferred?.resolve();
- }
+ if (DEV) {
+ for (const source of this.current.keys()) {
+ /** @type {Set} */ (source_stacks).add(source);
+ }
+ }
- batch_values = null;
+ next_batch.#process();
+ }
}
/**
@@ -257,7 +291,7 @@ export class Batch {
* @param {Effect[]} effects
* @param {Effect[]} render_effects
*/
- #traverse_effect_tree(root, effects, render_effects) {
+ #traverse(root, effects, render_effects) {
root.f ^= CLEAN;
var effect = root.first;
@@ -339,32 +373,54 @@ export class Batch {
activate() {
current_batch = this;
- this.apply();
}
deactivate() {
- // If we're not the current batch, don't deactivate,
- // else we could create zombie batches that are never flushed
- if (current_batch !== this) return;
-
current_batch = null;
batch_values = null;
}
flush() {
- if (queued_root_effects.length > 0) {
+ var source_stacks = DEV ? new Set() : null;
+
+ try {
+ is_processing = true;
current_batch = this;
- flush_effects();
- } else if (this.#pending === 0 && !this.is_fork) {
- // append/remove branches
- for (const fn of this.#commit_callbacks) fn(this);
- this.#commit_callbacks.clear();
- this.#commit();
- this.#deferred?.resolve();
- }
+ // we only reschedule previously-deferred effects if we expect
+ // to be able to run them after processing the batch
+ if (!this.#is_deferred()) {
+ for (const e of this.#dirty_effects) {
+ this.#maybe_dirty_effects.delete(e);
+ set_signal_status(e, DIRTY);
+ this.schedule(e);
+ }
- this.deactivate();
+ for (const e of this.#maybe_dirty_effects) {
+ set_signal_status(e, MAYBE_DIRTY);
+ this.schedule(e);
+ }
+ }
+
+ this.#process();
+ } finally {
+ flush_count = 0;
+ last_scheduled_effect = null;
+ collected_effects = null;
+ legacy_updates = null;
+ is_processing = false;
+
+ current_batch = null;
+ batch_values = null;
+
+ old_values.clear();
+
+ if (DEV) {
+ for (const source of /** @type {Set} */ (source_stacks)) {
+ source.updated = null;
+ }
+ }
+ }
}
discard() {
@@ -415,9 +471,7 @@ export class Batch {
// Re-run async/block effects that depend on distinct values changed in both batches
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
if (others.length > 0) {
- // Avoid running queued root effects on the wrong branch
- var prev_queued_root_effects = queued_root_effects;
- queued_root_effects = [];
+ batch.activate();
/** @type {Set} */
const marked = new Set();
@@ -427,20 +481,17 @@ export class Batch {
mark_effects(source, others, marked, checked);
}
- if (queued_root_effects.length > 0) {
- current_batch = batch;
+ if (batch.#roots.length > 0) {
batch.apply();
- for (const root of queued_root_effects) {
- batch.#traverse_effect_tree(root, [], []);
+ for (const root of batch.#roots) {
+ batch.#traverse(root, [], []);
}
// TODO do we need to do anything with the dummy effect arrays?
-
- batch.deactivate();
}
- queued_root_effects = prev_queued_root_effects;
+ batch.deactivate();
}
}
@@ -462,46 +513,22 @@ export class Batch {
}
/**
- *
* @param {boolean} blocking
+ * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
- decrement(blocking) {
+ decrement(blocking, skip) {
this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1;
- if (this.#decrement_queued) return;
+ if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
queue_micro_task(() => {
this.#decrement_queued = false;
-
- if (!this.#is_deferred()) {
- // we only reschedule previously-deferred effects if we expect
- // to be able to run them after processing the batch
- this.revive();
- } else if (queued_root_effects.length > 0) {
- // if other effects are scheduled, process the batch _without_
- // rescheduling the previously-deferred effects
- this.flush();
- }
+ this.flush();
});
}
- revive() {
- for (const e of this.#dirty_effects) {
- this.#maybe_dirty_effects.delete(e);
- set_signal_status(e, DIRTY);
- schedule_effect(e);
- }
-
- for (const e of this.#maybe_dirty_effects) {
- set_signal_status(e, MAYBE_DIRTY);
- schedule_effect(e);
- }
-
- this.flush();
- }
-
/** @param {(batch: Batch) => void} fn */
oncommit(fn) {
this.#commit_callbacks.add(fn);
@@ -519,17 +546,20 @@ export class Batch {
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
- batches.add(current_batch);
- if (!is_flushing_sync) {
- queue_micro_task(() => {
- if (current_batch !== batch) {
- // a flushSync happened in the meantime
- return;
- }
+ if (!is_processing) {
+ batches.add(current_batch);
+
+ if (!is_flushing_sync) {
+ queue_micro_task(() => {
+ if (current_batch !== batch) {
+ // a flushSync happened in the meantime
+ return;
+ }
- batch.flush();
- });
+ batch.flush();
+ });
+ }
}
}
@@ -554,6 +584,63 @@ export class Batch {
}
}
}
+
+ /**
+ *
+ * @param {Effect} effect
+ */
+ schedule(effect) {
+ last_scheduled_effect = effect;
+
+ // defer render effects inside a pending boundary
+ // TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT ā we can remove later
+ if (
+ effect.b?.is_pending &&
+ (effect.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
+ (effect.f & REACTION_RAN) === 0
+ ) {
+ effect.b.defer_effect(effect);
+ 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);
+ }
}
/**
@@ -571,8 +658,8 @@ export function flushSync(fn) {
var result;
if (fn) {
- if (current_batch !== null) {
- flush_effects();
+ if (current_batch !== null && !current_batch.is_fork) {
+ current_batch.flush();
}
result = fn();
@@ -581,87 +668,42 @@ export function flushSync(fn) {
while (true) {
flush_tasks();
- if (queued_root_effects.length === 0) {
- current_batch?.flush();
-
- // we need to check again, in case we just updated an `$effect.pending()`
- if (queued_root_effects.length === 0) {
- // this would be reset in `flush_effects()` but since we are early returning here,
- // we need to reset it here as well in case the first time there's 0 queued root effects
- last_scheduled_effect = null;
-
- return /** @type {T} */ (result);
- }
+ if (current_batch === null) {
+ return /** @type {T} */ (result);
}
- flush_effects();
+ current_batch.flush();
}
} finally {
is_flushing_sync = was_flushing_sync;
}
}
-function flush_effects() {
- var source_stacks = DEV ? new Set() : null;
-
- try {
- var flush_count = 0;
-
- while (queued_root_effects.length > 0) {
- var batch = Batch.ensure();
-
- if (flush_count++ > 1000) {
- if (DEV) {
- var updates = new Map();
-
- for (const source of batch.current.keys()) {
- for (const [stack, update] of source.updated ?? []) {
- var entry = updates.get(stack);
+function infinite_loop_guard() {
+ if (DEV) {
+ var updates = new Map();
- if (!entry) {
- entry = { error: update.error, count: 0 };
- updates.set(stack, entry);
- }
+ for (const source of /** @type {Batch} */ (current_batch).current.keys()) {
+ for (const [stack, update] of source.updated ?? []) {
+ var entry = updates.get(stack);
- entry.count += update.count;
- }
- }
-
- for (const update of updates.values()) {
- if (update.error) {
- // eslint-disable-next-line no-console
- console.error(update.error);
- }
- }
+ if (!entry) {
+ entry = { error: update.error, count: 0 };
+ updates.set(stack, entry);
}
- infinite_loop_guard();
- }
-
- batch.process(queued_root_effects);
- old_values.clear();
-
- if (DEV) {
- for (const source of batch.current.keys()) {
- /** @type {Set} */ (source_stacks).add(source);
- }
+ entry.count += update.count;
}
}
- } finally {
- queued_root_effects = [];
- last_scheduled_effect = null;
- collected_effects = null;
-
- if (DEV) {
- for (const source of /** @type {Set} */ (source_stacks)) {
- source.updated = null;
+ for (const update of updates.values()) {
+ if (update.error) {
+ // eslint-disable-next-line no-console
+ console.error(update.error);
}
}
}
-}
-function infinite_loop_guard() {
try {
e.effect_update_depth_exceeded();
} catch (error) {
@@ -831,60 +873,11 @@ function depends_on(reaction, sources, checked) {
}
/**
- * @param {Effect} signal
+ * @param {Effect} effect
* @returns {void}
*/
-export function schedule_effect(signal) {
- var effect = (last_scheduled_effect = signal);
-
- var boundary = effect.b;
-
- // defer render effects inside a pending boundary
- // TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT ā we can remove later
- if (
- boundary?.is_pending &&
- (signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
- (signal.f & REACTION_RAN) === 0
- ) {
- boundary.defer_effect(signal);
- return;
- }
-
- while (effect.parent !== null) {
- effect = effect.parent;
- var flags = effect.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 && effect === 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;
- }
-
- effect.f ^= CLEAN;
- }
- }
-
- queued_root_effects.push(effect);
+export function schedule_effect(effect) {
+ /** @type {Batch} */ (current_batch).schedule(effect);
}
/** @type {Source[]} */
@@ -1061,7 +1054,7 @@ export function fork(fn) {
flush_eager_effects();
});
- batch.revive();
+ batch.flush();
await settled;
},
discard: () => {
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index d8989ef03d..036256284c 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -10,7 +10,8 @@ import {
ASYNC,
WAS_MARKED,
DESTROYED,
- CLEAN
+ CLEAN,
+ REACTION_RAN
} from '#client/constants';
import {
active_reaction,
@@ -36,7 +37,6 @@ import {
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
-import { Boundary } from '../dom/blocks/boundary.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch } from './batch.js';
@@ -125,6 +125,8 @@ export function async_derived(fn, label, location) {
async_effect(() => {
if (DEV) current_async_effect = active_effect;
+ var effect = /** @type {Effect} */ (active_effect);
+
/** @type {ReturnType>} */
var d = deferred();
promise = d.promise;
@@ -143,7 +145,10 @@ export function async_derived(fn, label, location) {
var batch = /** @type {Batch} */ (current_batch);
- if (should_suspend) {
+ // we only increment the batch's pending state for updates, not creation, otherwise
+ // we will decrement to zero before the work that depends on this promise (e.g. a
+ // template effect) has initialized, causing the batch to resolve prematurely
+ if (should_suspend && (effect.f & REACTION_RAN) !== 0) {
var decrement_pending = increment_pending();
if (/** @type {Boundary} */ (parent.b).is_rendered()) {
@@ -166,17 +171,26 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
- current_async_effect = null;
+ if (DEV) current_async_effect = 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);
+ }
+
+ if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) {
+ return;
+ }
batch.activate();
if (error) {
- if (error !== STALE_REACTION) {
- signal.f |= ERROR_VALUE;
+ signal.f |= ERROR_VALUE;
- // @ts-expect-error the error is the wrong type, but we don't care
- internal_set(signal, error);
- }
+ // @ts-expect-error the error is the wrong type, but we don't care
+ internal_set(signal, error);
} else {
if ((signal.f & ERROR_VALUE) !== 0) {
signal.f ^= ERROR_VALUE;
@@ -203,10 +217,6 @@ export function async_derived(fn, label, location) {
}
}
- if (decrement_pending) {
- decrement_pending();
- }
-
batch.deactivate();
};
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 3118851277..8805ad2e1d 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -41,7 +41,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
-import { Batch, collected_effects, schedule_effect } from './batch.js';
+import { Batch, collected_effects } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@@ -129,7 +129,7 @@ function create_effect(type, fn) {
collected_effects.push(effect);
} else {
// schedule for later
- schedule_effect(effect);
+ Batch.ensure().schedule(effect);
}
} else if (fn !== null) {
try {
diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js
index 8353eb39e2..f63d4daedd 100644
--- a/packages/svelte/src/internal/client/reactivity/props.js
+++ b/packages/svelte/src/internal/client/reactivity/props.js
@@ -22,6 +22,7 @@ import { DESTROYED, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants';
import { proxy } from '../proxy.js';
import { capture_store_binding } from './store.js';
import { legacy_mode_flag } from '../../flags/index.js';
+import { effect, render_effect } from './effects.js';
/**
* @param {((value?: number) => number)} fn
@@ -296,7 +297,7 @@ export function prop(props, key, flags, fallback) {
};
/** @type {((v: V) => void) | undefined} */
- var setter;
+ let setter;
if (bindable) {
// Can be the case when someone does `mount(Component, props)` with `let props = $state({...})`
@@ -308,6 +309,7 @@ export function prop(props, key, flags, fallback) {
(is_entry_props && key in props ? (v) => (props[key] = v) : undefined);
}
+ /** @type {V} */
var initial_value;
var is_store_sub = false;
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 52dd4ebe41..f4ae92659c 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -35,7 +35,13 @@ import { includes } from '../../shared/utils.js';
import { tag_proxy } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js';
import { component_context, is_runes } from '../context.js';
-import { Batch, batch_values, eager_block_effects, schedule_effect } from './batch.js';
+import {
+ Batch,
+ batch_values,
+ eager_block_effects,
+ schedule_effect,
+ legacy_updates
+} from './batch.js';
import { proxy } from '../proxy.js';
import { execute_derived } from './deriveds.js';
import { set_signal_status, update_derived_status } from './status.js';
@@ -162,16 +168,17 @@ export function set(source, value, should_proxy = false) {
tag_proxy(new_value, /** @type {string} */ (source.label));
}
- return internal_set(source, new_value);
+ return internal_set(source, new_value, legacy_updates);
}
/**
* @template V
* @param {Source} source
* @param {V} value
+ * @param {Effect[] | null} [updated_during_traversal]
* @returns {V}
*/
-export function internal_set(source, value) {
+export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
var old_value = source.v;
@@ -231,7 +238,7 @@ export function internal_set(source, value) {
// For debugging, in case you want to know which reactions are being scheduled:
// log_reactions(source);
- mark_reactions(source, DIRTY);
+ mark_reactions(source, DIRTY, updated_during_traversal);
// 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
@@ -317,9 +324,10 @@ export function increment(source) {
/**
* @param {Value} signal
* @param {number} status should be DIRTY or MAYBE_DIRTY
+ * @param {Effect[] | null} updated_during_traversal
* @returns {void}
*/
-function mark_reactions(signal, status) {
+function mark_reactions(signal, status, updated_during_traversal) {
var reactions = signal.reactions;
if (reactions === null) return;
@@ -357,14 +365,20 @@ function mark_reactions(signal, status) {
reaction.f |= WAS_MARKED;
}
- mark_reactions(derived, MAYBE_DIRTY);
+ mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
}
} else if (not_dirty) {
+ var effect = /** @type {Effect} */ (reaction);
+
if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {
- eager_block_effects.add(/** @type {Effect} */ (reaction));
+ eager_block_effects.add(effect);
}
- schedule_effect(/** @type {Effect} */ (reaction));
+ if (updated_during_traversal !== null) {
+ updated_during_traversal.push(effect);
+ } else {
+ schedule_effect(effect);
+ }
}
}
}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js
index 3de81a507b..de1844fbea 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js
@@ -1,4 +1,4 @@
-import { flushSync, tick } from 'svelte';
+import { tick } from 'svelte';
import { test } from '../../test';
export default test({
@@ -7,12 +7,7 @@ export default test({
`,
async test({ assert, target }) {
- await Promise.resolve();
- await Promise.resolve();
- await Promise.resolve();
- await Promise.resolve();
- flushSync();
-
+ await tick();
assert.htmlEqual(target.innerHTML, 'hello
');
}
});
From 13042089708581cc5c2687eaa79ed481bf7bfed4 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 9 Mar 2026 15:31:05 +0100
Subject: [PATCH 22/28] fix: set deferreds on initial suspense, too (#17884)
The combination of #17873 and #17805 resulted in a bad merge of the
latter because it wasn't up to date with main. This fixes it. No
changeset because not released yet.
---------
Co-authored-by: Rich Harris
---
.../src/internal/client/reactivity/deriveds.js | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 036256284c..3478784309 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -1,5 +1,6 @@
/** @import { Derived, Effect, Source } from '#client' */
/** @import { Batch } from './batch.js'; */
+/** @import { Boundary } from '../dom/blocks/boundary.js'; */
import { DEV } from 'esm-env';
import {
ERROR_VALUE,
@@ -145,11 +146,13 @@ export function async_derived(fn, label, location) {
var batch = /** @type {Batch} */ (current_batch);
- // we only increment the batch's pending state for updates, not creation, otherwise
- // we will decrement to zero before the work that depends on this promise (e.g. a
- // template effect) has initialized, causing the batch to resolve prematurely
- if (should_suspend && (effect.f & REACTION_RAN) !== 0) {
- var decrement_pending = increment_pending();
+ if (should_suspend) {
+ // we only increment the batch's pending state for updates, not creation, otherwise
+ // we will decrement to zero before the work that depends on this promise (e.g. a
+ // template effect) has initialized, causing the batch to resolve prematurely
+ if ((effect.f & REACTION_RAN) !== 0) {
+ var decrement_pending = increment_pending();
+ }
if (/** @type {Boundary} */ (parent.b).is_rendered()) {
deferreds.get(batch)?.reject(STALE_REACTION);
From 1892988074685064d4a2a6ea09c069eada7580de Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 9 Mar 2026 15:40:06 +0100
Subject: [PATCH 23/28] fix: don't access inert block effects (#17882)
In #17837 we added logic to not schedule another batch during
resumption. The logic in there turns out to be flawed - it's dangerous
to keep accessing inert block effects, because if they're nested they
could access properties that no longer exist (because the outer if makes
the inner if obsolete).
So this PR basically reverts #17837 and instead schedules another batch
again under the assumption that this will only happen during the commit
phase, and all that's gonna happen is that it will schedule another
batch, which is safe.
Fixes #17866 Fixes #17878
This reverts commit 2f12b6070107c1457a2f0b9d7ac652aee9e2394c.
Co-authored-by: Rich Harris
---
.changeset/light-jokes-draw.md | 5 +++++
.../src/internal/client/dom/blocks/branches.js | 6 +-----
.../src/internal/client/reactivity/batch.js | 16 ++++------------
.../src/internal/client/reactivity/effects.js | 12 +++++++-----
.../_config.js | 14 ++++++++++++++
.../main.svelte | 17 +++++++++++++++++
6 files changed, 48 insertions(+), 22 deletions(-)
create mode 100644 .changeset/light-jokes-draw.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/main.svelte
diff --git a/.changeset/light-jokes-draw.md b/.changeset/light-jokes-draw.md
new file mode 100644
index 0000000000..775a14b76e
--- /dev/null
+++ b/.changeset/light-jokes-draw.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't access inert block effects
diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js
index 344b8e9c04..a8096e0a58 100644
--- a/packages/svelte/src/internal/client/dom/blocks/branches.js
+++ b/packages/svelte/src/internal/client/dom/blocks/branches.js
@@ -1,5 +1,4 @@
/** @import { Effect, TemplateNode } from '#client' */
-import { INERT } from '#client/constants';
import { Batch, current_batch } from '../../reactivity/batch.js';
import {
branch,
@@ -88,7 +87,7 @@ export class BranchManager {
// effect is currently offscreen. put it in the DOM
var offscreen = this.#offscreen.get(key);
- if (offscreen && (offscreen.effect.f & INERT) === 0) {
+ if (offscreen) {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
@@ -125,9 +124,6 @@ export class BranchManager {
// or those that are already outroing (else the transition is aborted and the effect destroyed right away)
if (k === key || this.#outroing.has(k)) continue;
- // don't destroy branches that are inside outroing blocks
- if ((effect.f & INERT) !== 0) continue;
-
const on_destroy = () => {
const keys = Array.from(this.#batches.values());
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index e6dcc95510..fc4c70c6b8 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -301,26 +301,18 @@ export class Batch {
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
- var inert = (flags & INERT) !== 0;
- var skip = is_skippable_branch || this.#skipped_branches.has(effect);
+ var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
if (!skip && effect.fn !== null) {
if (is_branch) {
- if (!inert) effect.f ^= CLEAN;
+ effect.f ^= CLEAN;
} else if ((flags & EFFECT) !== 0) {
effects.push(effect);
- } else if ((flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0 && (async_mode_flag || inert)) {
+ } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
render_effects.push(effect);
} else if (is_dirty(effect)) {
+ if ((flags & BLOCK_EFFECT) !== 0) this.#maybe_dirty_effects.add(effect);
update_effect(effect);
-
- if ((flags & BLOCK_EFFECT) !== 0) {
- this.#maybe_dirty_effects.add(effect);
-
- // if this is inside an outroing block, ensure that the block
- // re-runs if the outro is later aborted
- if (inert) set_signal_status(effect, DIRTY);
- }
}
var child = effect.first;
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 8805ad2e1d..49fe68d90b 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -45,7 +45,6 @@ import { Batch, collected_effects } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
-import { async_mode_flag } from '../../flags/index.js';
/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
@@ -679,10 +678,13 @@ function resume_children(effect, local) {
if ((effect.f & INERT) === 0) return;
effect.f ^= INERT;
- // Mark branches as clean so that effects can be scheduled, but only in async mode
- // (in legacy mode, effect resumption happens during traversal)
- if (async_mode_flag && (effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) === 0) {
- effect.f ^= CLEAN;
+ // If a dependency of this effect changed while it was paused,
+ // schedule the effect to update. we don't use `is_dirty`
+ // here because we don't want to eagerly recompute a derived like
+ // `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined
+ if ((effect.f & CLEAN) === 0) {
+ set_signal_status(effect, DIRTY);
+ Batch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe
}
var child = effect.first;
diff --git a/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/_config.js
new file mode 100644
index 0000000000..d75b15ed2d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/_config.js
@@ -0,0 +1,14 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+import { raf } from '../../../animation-helpers';
+
+export default test({
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ await tick();
+ raf.tick(100);
+ assert.htmlEqual(target.innerHTML, `clear `);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/main.svelte
new file mode 100644
index 0000000000..6b7329f973
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/transition-if-nested-access-property/main.svelte
@@ -0,0 +1,17 @@
+
+
+ (data = null)}>clear
+
+{#if data}
+ {#key data?.id}
+ keyed
+ {/key}
+
+ {#if data.id}
+ sibling
+ {/if}
+{/if}
\ No newline at end of file
From ef95f34ae1ceb85deaaa8eb7b869ac108f1b5900 Mon Sep 17 00:00:00 2001
From: Paolo Ricciuti
Date: Mon, 9 Mar 2026 15:40:50 +0100
Subject: [PATCH 24/28] docs: add skill true frontmatter (#17877)
This adds a `skill: true` to the frontmatter of the bestpractices
skill/doc. This way when we sync we can concern ourselves to only the
documents that need to be skills (we will write one for sveltekit too)
without targeting specific files in the `ai-tools` repo
---
documentation/docs/07-misc/01-best-practices.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/documentation/docs/07-misc/01-best-practices.md b/documentation/docs/07-misc/01-best-practices.md
index ac275e4d9a..66f7da2613 100644
--- a/documentation/docs/07-misc/01-best-practices.md
+++ b/documentation/docs/07-misc/01-best-practices.md
@@ -1,5 +1,6 @@
---
title: Best practices
+skill: true
name: svelte-core-bestpractices
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
---
From d2b347042c8cdc080244e6beff3d263e80f1b711 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 9 Mar 2026 11:25:38 -0400
Subject: [PATCH 25/28] Version Packages (#17854)
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.53.8
### Patch Changes
- fix: `{@html}` no longer duplicates content inside `contenteditable`
elements ([#17853](https://github.com/sveltejs/svelte/pull/17853))
- fix: don't access inert block effects
([#17882](https://github.com/sveltejs/svelte/pull/17882))
- fix: handle asnyc updates within pending boundary
([#17873](https://github.com/sveltejs/svelte/pull/17873))
- perf: avoid re-traversing the effect tree after `$:` assignments
([#17848](https://github.com/sveltejs/svelte/pull/17848))
- chore: simplify scheduling logic
([#17805](https://github.com/sveltejs/svelte/pull/17805))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/html-contenteditable-fix.md | 5 -----
.changeset/light-jokes-draw.md | 5 -----
.changeset/nasty-friends-crash.md | 5 -----
.changeset/ninety-kings-attend.md | 5 -----
.changeset/slick-bars-train.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/html-contenteditable-fix.md
delete mode 100644 .changeset/light-jokes-draw.md
delete mode 100644 .changeset/nasty-friends-crash.md
delete mode 100644 .changeset/ninety-kings-attend.md
delete mode 100644 .changeset/slick-bars-train.md
diff --git a/.changeset/html-contenteditable-fix.md b/.changeset/html-contenteditable-fix.md
deleted file mode 100644
index 5cae7f6234..0000000000
--- a/.changeset/html-contenteditable-fix.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: `{@html}` no longer duplicates content inside `contenteditable` elements
diff --git a/.changeset/light-jokes-draw.md b/.changeset/light-jokes-draw.md
deleted file mode 100644
index 775a14b76e..0000000000
--- a/.changeset/light-jokes-draw.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: don't access inert block effects
diff --git a/.changeset/nasty-friends-crash.md b/.changeset/nasty-friends-crash.md
deleted file mode 100644
index 5895f3752a..0000000000
--- a/.changeset/nasty-friends-crash.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: handle asnyc updates within pending boundary
diff --git a/.changeset/ninety-kings-attend.md b/.changeset/ninety-kings-attend.md
deleted file mode 100644
index 40913dab67..0000000000
--- a/.changeset/ninety-kings-attend.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: avoid re-traversing the effect tree after `$:` assignments
diff --git a/.changeset/slick-bars-train.md b/.changeset/slick-bars-train.md
deleted file mode 100644
index 795f8d806f..0000000000
--- a/.changeset/slick-bars-train.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-chore: simplify scheduling logic
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 5e1a5cc6f1..3c796623a3 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,19 @@
# svelte
+## 5.53.8
+
+### Patch Changes
+
+- fix: `{@html}` no longer duplicates content inside `contenteditable` elements ([#17853](https://github.com/sveltejs/svelte/pull/17853))
+
+- fix: don't access inert block effects ([#17882](https://github.com/sveltejs/svelte/pull/17882))
+
+- fix: handle asnyc updates within pending boundary ([#17873](https://github.com/sveltejs/svelte/pull/17873))
+
+- perf: avoid re-traversing the effect tree after `$:` assignments ([#17848](https://github.com/sveltejs/svelte/pull/17848))
+
+- chore: simplify scheduling logic ([#17805](https://github.com/sveltejs/svelte/pull/17805))
+
## 5.53.7
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 5f5b6c3615..a26deef7e5 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.53.7",
+ "version": "5.53.8",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index af92199797..a6f9f0f907 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.53.7';
+export const VERSION = '5.53.8';
export const PUBLIC_VERSION = '5';
From be36c934c4ed5d6e43818a7b1ff6cdb7ecb65412 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Mon, 9 Mar 2026 22:43:06 +0100
Subject: [PATCH 26/28] fix: better `bind:this` cleanup timing (#17885)
This removes the `queue_micro_task`-workaround we employed in
`bind:this` in favor of a search for the nearest component effect /
effect that is still getting destroyed, whichever comes first.
We used `queue_micro_task` mainly due to timing issues with components
wanting to access the bound property on teardown still, and when nulling
it out on cleanup of the bind-this-effect itself, that was too early.
The microtask is too late though in some cases, when accessing
properties of objects that are no longer there. The targeted
upwards-walk solves this while keeping the binding around as long as
needed.
For that I had to add a new `DESTROYING` flag. We _could_ have done it
without one and by deleting code in `props.js` where we don't do
`get(d)` when the prop derived is destroyed, but I wanted to keep that
because you could still run into an access error if you e.g. access the
property in a timeout.
Alternative to #17862
---
.changeset/vast-moles-burn.md | 5 ++++
.../svelte/src/internal/client/constants.js | 2 ++
.../svelte/src/internal/client/context.js | 3 +-
.../client/dom/elements/bindings/this.js | 29 +++++++++++++++----
.../src/internal/client/reactivity/effects.js | 8 +++--
.../src/internal/client/reactivity/props.js | 4 +--
.../svelte/src/internal/client/types.d.ts | 6 ++++
.../bind-this-destroy-timing/Inner.svelte | 9 ++++++
.../bind-this-destroy-timing/_config.js | 18 ++++++++++++
.../bind-this-destroy-timing/main.svelte | 19 ++++++++++++
.../bind-this-destroy-timing2/_config.js | 12 ++++++++
.../bind-this-destroy-timing2/main.svelte | 10 +++++++
12 files changed, 113 insertions(+), 12 deletions(-)
create mode 100644 .changeset/vast-moles-burn.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte
diff --git a/.changeset/vast-moles-burn.md b/.changeset/vast-moles-burn.md
new file mode 100644
index 0000000000..8d5ca73189
--- /dev/null
+++ b/.changeset/vast-moles-burn.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: better `bind:this` cleanup timing
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index 3525539f1d..df96f4899b 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -29,6 +29,8 @@ export const INERT = 1 << 13;
export const DESTROYED = 1 << 14;
/** Set once a reaction has run for the first time */
export const REACTION_RAN = 1 << 15;
+/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */
+export const DESTROYING = 1 << 25;
// Flags exclusive to effects
/**
diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js
index 36c735272c..0baef5c63e 100644
--- a/packages/svelte/src/internal/client/context.js
+++ b/packages/svelte/src/internal/client/context.js
@@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js';
import { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
-import { BRANCH_EFFECT, REACTION_RAN } from './constants.js';
+import { BRANCH_EFFECT } from './constants.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@@ -182,6 +182,7 @@ export function push(props, runes = false, fn) {
e: null,
s: props,
x: null,
+ r: /** @type {Effect} */ (active_effect),
l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null
};
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js
index f2e715113f..c39ca34062 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js
@@ -1,7 +1,8 @@
-import { STATE_SYMBOL } from '#client/constants';
+/** @import { ComponentContext, Effect } from '#client' */
+import { DESTROYING, STATE_SYMBOL } from '#client/constants';
+import { component_context } from '../../../context.js';
import { effect, render_effect } from '../../../reactivity/effects.js';
-import { untrack } from '../../../runtime.js';
-import { queue_micro_task } from '../../task.js';
+import { active_effect, untrack } from '../../../runtime.js';
/**
* @param {any} bound_value
@@ -23,6 +24,9 @@ function is_bound_this(bound_value, element_or_component) {
* @returns {void}
*/
export function bind_this(element_or_component = {}, update, get_value, get_parts) {
+ var component_effect = /** @type {ComponentContext} */ (component_context).r;
+ var parent = /** @type {Effect} */ (active_effect);
+
effect(() => {
/** @type {unknown[]} */
var old_parts;
@@ -48,12 +52,25 @@ export function bind_this(element_or_component = {}, update, get_value, get_part
});
return () => {
- // We cannot use effects in the teardown phase, we we use a microtask instead.
- queue_micro_task(() => {
+ // When the bind:this effect is destroyed, we go up the effect parent chain until we find the last parent effect that is destroyed,
+ // or the effect containing the component bind:this is in (whichever comes first). That way we can time the nulling of the binding
+ // as close to user/developer expectation as possible.
+ // TODO Svelte 6: Decide if we want to keep this logic or just always null the binding in the component effect's teardown
+ // (which would be simpler, but less intuitive in some cases, and breaks the `ondestroy-before-cleanup` test)
+ let p = parent;
+ while (p !== component_effect && p.parent !== null && p.parent.f & DESTROYING) {
+ p = p.parent;
+ }
+ const teardown = () => {
if (parts && is_bound_this(get_value(...parts), element_or_component)) {
update(null, ...parts);
}
- });
+ };
+ const original_teardown = p.teardown;
+ p.teardown = () => {
+ teardown();
+ original_teardown?.();
+ };
};
});
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 49fe68d90b..aeffeedddd 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -34,7 +34,8 @@ import {
USER_EFFECT,
ASYNC,
CONNECTED,
- MANAGED_EFFECT
+ MANAGED_EFFECT,
+ DESTROYING
} from '#client/constants';
import * as e from '../errors.js';
import { DEV } from 'esm-env';
@@ -520,9 +521,9 @@ export function destroy_effect(effect, remove_dom = true) {
removed = true;
}
+ set_signal_status(effect, DESTROYING);
destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0);
- set_signal_status(effect, DESTROYED);
var transitions = effect.nodes && effect.nodes.t;
@@ -534,6 +535,9 @@ export function destroy_effect(effect, remove_dom = true) {
execute_effect_teardown(effect);
+ effect.f ^= DESTROYING;
+ effect.f |= DESTROYED;
+
var parent = effect.parent;
// If the parent doesn't have any children, then skip this work altogether
diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js
index f63d4daedd..e208d3b6f6 100644
--- a/packages/svelte/src/internal/client/reactivity/props.js
+++ b/packages/svelte/src/internal/client/reactivity/props.js
@@ -419,9 +419,7 @@ export function prop(props, key, flags, fallback) {
// special case ā avoid recalculating the derived if we're in a
// teardown function and the prop was overridden locally, or the
- // component was already destroyed (this latter part is necessary
- // because `bind:this` can read props after the component has
- // been destroyed. TODO simplify `bind:this`
+ // component was already destroyed (people could access props in a timeout)
if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) {
return d.v;
}
diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts
index f4fc81170d..2320e7b510 100644
--- a/packages/svelte/src/internal/client/types.d.ts
+++ b/packages/svelte/src/internal/client/types.d.ts
@@ -38,6 +38,12 @@ export type ComponentContext = {
* @deprecated remove in 6.0
*/
x: Record | null;
+ /**
+ * The parent effect of this component
+ * TODO 6.0 this is used to control `bind:this` timing that might change,
+ * in which case we can remove this property
+ */
+ r: Effect;
/**
* legacy stuff
* @deprecated remove in 6.0
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte
new file mode 100644
index 0000000000..e9e6e0d18d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/Inner.svelte
@@ -0,0 +1,9 @@
+
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js
new file mode 100644
index 0000000000..e0bf4e3af0
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/_config.js
@@ -0,0 +1,18 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ clear
+
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte
new file mode 100644
index 0000000000..11e75b8b6d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing/main.svelte
@@ -0,0 +1,19 @@
+
+
+{#if value}
+ {@const result = value}
+
+{/if}
+
+ (value = undefined)}>clear
+{externalView}
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js
new file mode 100644
index 0000000000..b48efa0fec
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/_config.js
@@ -0,0 +1,12 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `clear `);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte
new file mode 100644
index 0000000000..8b60d693eb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/bind-this-destroy-timing2/main.svelte
@@ -0,0 +1,10 @@
+
+
+{#if value}
+ {value}
+{/if}
+
+ (value = undefined)}>clear
From d54361b97f344300e599eea4ad538cd2ab78594d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 9 Mar 2026 20:25:06 -0400
Subject: [PATCH 27/28] Version Packages (#17886)
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.53.9
### Patch Changes
- fix: better `bind:this` cleanup timing
([#17885](https://github.com/sveltejs/svelte/pull/17885))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/vast-moles-burn.md | 5 -----
packages/svelte/CHANGELOG.md | 6 ++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
4 files changed, 8 insertions(+), 7 deletions(-)
delete mode 100644 .changeset/vast-moles-burn.md
diff --git a/.changeset/vast-moles-burn.md b/.changeset/vast-moles-burn.md
deleted file mode 100644
index 8d5ca73189..0000000000
--- a/.changeset/vast-moles-burn.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: better `bind:this` cleanup timing
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 3c796623a3..f09032294a 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,11 @@
# svelte
+## 5.53.9
+
+### Patch Changes
+
+- fix: better `bind:this` cleanup timing ([#17885](https://github.com/sveltejs/svelte/pull/17885))
+
## 5.53.8
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index a26deef7e5..17fc4335d5 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.53.8",
+ "version": "5.53.9",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index a6f9f0f907..e481799ad5 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.53.8';
+export const VERSION = '5.53.9';
export const PUBLIC_VERSION = '5';
From 51d305d18a455c7ffce8e50ffebf092f408fd809 Mon Sep 17 00:00:00 2001
From: Rohan Santhosh
Date: Tue, 10 Mar 2026 22:08:26 +0800
Subject: [PATCH 28/28] docs: fix usable spelling in migration guide (#17889)
### Summary
Fix spelling in the v5 migration guide ("useable" -> "usable").
### Before submitting the PR, please make sure you do the following
- [ ] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [ ] Ideally, include a test that fails without this PR but passes with
it.
- [ ] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`). (Not applicable; docs-only change.)
### Tests and linting
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm
lint`. (Not run; docs-only change.)
Co-authored-by: rohan436
---
documentation/docs/07-misc/07-v5-migration-guide.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/documentation/docs/07-misc/07-v5-migration-guide.md b/documentation/docs/07-misc/07-v5-migration-guide.md
index 580dbec6d4..9b1f2dec63 100644
--- a/documentation/docs/07-misc/07-v5-migration-guide.md
+++ b/documentation/docs/07-misc/07-v5-migration-guide.md
@@ -324,7 +324,7 @@ When spreading props, local event handlers must go _after_ the spread, or they r
>
> It was always possible to use component callback props, but because you had to listen to DOM events using `on:`, it made sense to use `createEventDispatcher` for component events due to syntactical consistency. Now that we have event attributes (`onclick`), it's the other way around: Callback props are now the more sensible thing to do.
>
-> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only useable on DOM elements.
+> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only usable on DOM elements.
>
> Multiple listeners for the same event are also no longer possible, but it was something of an anti-pattern anyway, since it impedes readability: if there are many attributes, it becomes harder to spot that there are two handlers unless they are right next to each other. It also implies that the two handlers are independent, when in fact something like `event.stopImmediatePropagation()` inside `one` would prevent `two` from being called.
>