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: image (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, + ` + + +

1

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

1

+

2

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

1

+

2

+

3

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

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 @@ + + + + + +{#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(` `); 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 + + + +

escaped

+ + +
+

title

+
+
    +
  • item
  • +
+ + 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, + ` + +

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 @@ + + + + + + {#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: '

hello

', + + 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, ''); + } +}); 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 @@ + + + + +{#if value} + {@const result = compute(value)} + {#if result.ready} +
+

{result.data}

+
+ {/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.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=immutable&package-manager=npm_and_yarn&previous-version=4.3.7&new-version=4.3.8)](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: '

hello

', - - 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, ''); - } -}); 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 @@ - - - - -{#if value} - {@const result = compute(value)} - {#if result.ready} -
-

{result.data}

-
- {/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 @@ + + + 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: ``, + + test({ assert, target }) { + const [button1, button2] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button1.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button2.click()); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => button2.click()); + assert.htmlEqual(target.innerHTML, ``); + } +}); 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, + ` + + + loading + ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + loading + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + loading + ` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 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 @@ + + + + + + + {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