From 6557a0a591d7e312103de8ea5c75d988f1b8e7c9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 08:12:09 -0500 Subject: [PATCH 1/8] fix: run effects in pending snippets (#17719) Boundaries are buggy: if the `pending` snippet contains state, changes to that state [won't cause updates](https://svelte.dev/playground/hello-world?version=5.51.2#H4sIAAAAAAAAE22SQW-DMAyF_0qUTSpoE92uFJh223Hate0hJWaNliZRYsoqxH9fQtJW6noD-33Pz4aRKnYAWtIPkFKTQVvJSQZcIPCcPtNOSHC0XI8UTyboQsHXE_VuTOGOIDHUdszBvXqrFYJCb0Mr11phsNmoDUpAYsFpeQTrSE3W25Uv-0bXqxaFVsT0bp8dmewhJ2PobNB7OSQcOrAWuKc-rT4IB8UgcP91dsvyVZRf_IvZK8tJ3VzoInXTiCuDvVVXlYkT5u50k9DtRYfZJd11XGq8FSlKAsPOre4V-uSPDhlC9hIE1fJ6GFXtekRvrlUrRftTjzF25J5q8jrN9xOqtXDwhw14RO7jc5bIzI-3-vihyp3358yeZmFlmpENTGD8CIu0GV_kU7U0TdxmfHBKGON3MqC4UN9ZPsVDBHzOe1bjuEzaad7230j_nyD8Ii3R9jBt_RsTchCK07Jj0sH0B6hNF6aqAgAA): ```svelte

{await push('resolved')}

{#snippet pending()}

{count}

{/snippet}
``` The issue is that the boundary's `this.#effect` has the `BOUNDARY_EFFECT` flag, and `this.#pending_effect` is a child thereof. Instead, `this.#main_effect` should have the flag. (It turns out `this.#failed_effect` _also_ needs the flag, because errors that occur in a `failed` snippet cause the boundary to re-render in its `failed` state, which I found somewhat confusing to be honest. Probably the right choice though.) I was able to simplify the code a bit, too. ~~(Actually now that I think about it do we need `this.#effect` at all? Will check.)~~ ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/many-dolls-argue.md | 5 + .../internal/client/dom/blocks/boundary.js | 142 +++++++----------- .../src/internal/client/reactivity/batch.js | 17 ++- .../async-boundary-pending-live/_config.js | 38 +++++ .../async-boundary-pending-live/main.svelte | 31 ++++ 5 files changed, 139 insertions(+), 94 deletions(-) create mode 100644 .changeset/many-dolls-argue.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte diff --git a/.changeset/many-dolls-argue.md b/.changeset/many-dolls-argue.md new file mode 100644 index 0000000000..54a9cef915 --- /dev/null +++ b/.changeset/many-dolls-argue.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: run effects in pending snippets diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index ef5f0e116d..da70cbb19d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,6 +1,5 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ import { - BLOCK_EFFECT, BOUNDARY_EFFECT, COMMENT_NODE, DIRTY, @@ -53,7 +52,7 @@ import { set_signal_status } from '../../reactivity/status.js'; * }} BoundaryProps */ -var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; +var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED; /** * @param {TemplateNode} node @@ -98,15 +97,10 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - /** @type {TemplateNode | null} */ - #pending_anchor = null; - #local_pending_count = 0; #pending_count = 0; #pending_count_update_queued = false; - #is_creating_fallback = false; - /** @type {Set} */ #dirty_effects = new Set(); @@ -142,51 +136,31 @@ export class Boundary { constructor(node, props, children) { this.#anchor = node; this.#props = props; - this.#children = children; - this.parent = /** @type {Effect} */ (active_effect).b; + this.#children = (anchor) => { + var effect = /** @type {Effect} */ (active_effect); - this.is_pending = !!this.#props.pending; + effect.b = this; + effect.f |= BOUNDARY_EFFECT; - this.#effect = block(() => { - /** @type {Effect} */ (active_effect).b = this; + children(anchor); + }; + + this.parent = /** @type {Effect} */ (active_effect).b; + this.#effect = block(() => { if (hydrating) { - const comment = this.#hydrate_open; + const comment = /** @type {Comment} */ (this.#hydrate_open); hydrate_next(); - const server_rendered_pending = - /** @type {Comment} */ (comment).nodeType === COMMENT_NODE && - /** @type {Comment} */ (comment).data === HYDRATION_START_ELSE; - - if (server_rendered_pending) { + if (comment.data === HYDRATION_START_ELSE) { this.#hydrate_pending_content(); } else { this.#hydrate_resolved_content(); - - if (this.#pending_count === 0) { - this.is_pending = false; - } } } else { - var anchor = this.#get_anchor(); - - try { - this.#main_effect = branch(() => children(anchor)); - } catch (error) { - this.error(error); - } - - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { - this.is_pending = false; - } + this.#render(); } - - return () => { - this.#pending_anchor?.remove(); - }; }, flags); if (hydrating) { @@ -206,19 +180,24 @@ export class Boundary { const pending = this.#props.pending; if (!pending) return; + this.is_pending = true; this.#pending_effect = branch(() => pending(this.#anchor)); queue_micro_task(() => { - var anchor = this.#get_anchor(); + var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); + var anchor = create_text(); + + fragment.append(anchor); this.#main_effect = this.#run(() => { Batch.ensure(); return branch(() => this.#children(anchor)); }); - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { + if (this.#pending_count === 0) { + this.#anchor.before(fragment); + this.#offscreen_fragment = null; + pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { this.#pending_effect = null; }); @@ -228,17 +207,28 @@ export class Boundary { }); } - #get_anchor() { - var anchor = this.#anchor; + #render() { + try { + this.is_pending = this.has_pending_snippet(); + this.#pending_count = 0; + this.#local_pending_count = 0; + + this.#main_effect = branch(() => { + this.#children(this.#anchor); + }); - if (this.is_pending) { - this.#pending_anchor = create_text(); - this.#anchor.before(this.#pending_anchor); + if (this.#pending_count > 0) { + var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); + move_effect(this.#main_effect, fragment); - anchor = this.#pending_anchor; + const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); + this.#pending_effect = branch(() => pending(this.#anchor)); + } else { + this.is_pending = false; + } + } catch (error) { + this.error(error); } - - return anchor; } /** @@ -262,7 +252,8 @@ export class Boundary { } /** - * @param {() => Effect | null} fn + * @template T + * @param {() => T} fn */ #run(fn) { var previous_effect = active_effect; @@ -285,20 +276,6 @@ export class Boundary { } } - #show_pending_snippet() { - const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); - - if (this.#main_effect !== null) { - this.#offscreen_fragment = document.createDocumentFragment(); - this.#offscreen_fragment.append(/** @type {TemplateNode} */ (this.#pending_anchor)); - move_effect(this.#main_effect, this.#offscreen_fragment); - } - - if (this.#pending_effect === null) { - this.#pending_effect = branch(() => pending(this.#anchor)); - } - } - /** * 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 @@ -383,7 +360,7 @@ export class Boundary { // If we have nothing to capture the error, or if we hit an error while // rendering the fallback, re-throw for another boundary to handle - if (this.#is_creating_fallback || (!onerror && !failed)) { + if (!onerror && !failed) { throw error; } @@ -423,31 +400,18 @@ export class Boundary { e.svelte_boundary_reset_onerror(); } - // If the failure happened while flushing effects, current_batch can be null - Batch.ensure(); - - this.#local_pending_count = 0; - if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { this.#failed_effect = null; }); } - // we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset - // but it would be really weird to show the parent's boundary on a child reset. - this.is_pending = this.has_pending_snippet(); + this.#run(() => { + // If the failure happened while flushing effects, current_batch can be null + Batch.ensure(); - this.#main_effect = this.#run(() => { - this.#is_creating_fallback = false; - return branch(() => this.#children(this.#anchor)); + this.#render(); }); - - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { - this.is_pending = false; - } }; queue_micro_task(() => { @@ -462,10 +426,16 @@ export class Boundary { if (failed) { this.#failed_effect = this.#run(() => { Batch.ensure(); - this.#is_creating_fallback = true; try { return branch(() => { + // errors in `failed` snippets cause the boundary to error again + // TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead + var effect = /** @type {Effect} */ (active_effect); + + effect.b = this; + effect.f |= BOUNDARY_EFFECT; + failed( this.#anchor, () => error, @@ -475,8 +445,6 @@ export class Boundary { } catch (error) { invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent)); return null; - } finally { - this.#is_creating_fallback = false; } }); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82a2d4f484..b382a4e3a5 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -293,16 +293,19 @@ export class Batch { } } - var parent = effect.parent; - effect = effect.next; - - while (effect === null && parent !== null) { - if (parent === pending_boundary) { + while (effect !== null) { + if (effect === pending_boundary) { pending_boundary = null; } - effect = parent.next; - parent = parent.parent; + var next = effect.next; + + if (next !== null) { + effect = next; + break; + } + + effect = effect.parent; } } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js new file mode 100644 index 0000000000..1d860cded5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + +

0

+ `, + + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

1

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

resolved

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte new file mode 100644 index 0000000000..764fd20a51 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte @@ -0,0 +1,31 @@ + + + + + + + +

{await push('resolved')}

+ + {#snippet pending()} +

{count}

+ {/snippet} +
From 60a425193c643665c99dfd7e0d5cea5b052127a9 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Tue, 17 Feb 2026 06:54:15 -0800 Subject: [PATCH 2/8] fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes (#17712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #17207 CSS attribute selectors for HTML enumerated attributes (like `method`, `type`, `dir`, etc.) are supposed to match case-insensitively per the HTML spec. Browsers handle this correctly — `form[method="get"]` matches `
`. But Svelte's CSS pruning was doing a strict case-sensitive comparison, which meant: 1. The selector got incorrectly flagged as unused (no `css_unused_selector` warning was shown when spreads were involved, but the selector was still pruned) 2. The scoping class wasn't applied to the matching element 3. Styles silently disappeared in production builds The fix adds a set of known HTML attributes with case-insensitive enumerated values (sourced from the HTML spec) and uses it during CSS attribute selector matching. The explicit CSS `s` flag still overrides this behavior, as expected. ### Before ```svelte

Hello

``` ### After The selector correctly matches and styles are applied. ### Test plan - Added `attribute-selector-html-case-insensitive` CSS test covering `form[method]` and `input[type]` cases - All 179 existing CSS tests pass - Verified the existing `attribute-selector-case-sensitive` test (using `s` flag) still works correctly - Compiler error tests and validator tests all pass --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/css-attribute-case-insensitive.md | 5 ++ .../phases/2-analyze/css/css-prune.js | 48 ++++++++++++++++++- .../expected.css | 11 +++++ .../expected.html | 1 + .../input.svelte | 23 +++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .changeset/css-attribute-case-insensitive.md create mode 100644 packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css create mode 100644 packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html create mode 100644 packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte diff --git a/.changeset/css-attribute-case-insensitive.md b/.changeset/css-attribute-case-insensitive.md new file mode 100644 index 0000000000..e5b3bcea2b --- /dev/null +++ b/.changeset/css-attribute-case-insensitive.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes 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 7242c69d8c..7e05d2e7d3 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 @@ -22,6 +22,50 @@ const whitelist_attribute_selector = new Map([ ['dialog', ['open']] ]); +/** + * HTML attributes whose enumerated values are case-insensitive per the HTML spec. + * CSS attribute selectors match these values case-insensitively in HTML documents. + * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors HTML spec} + */ +const case_insensitive_attributes = new Set([ + 'accept-charset', + 'autocapitalize', + 'autocomplete', + 'behavior', + 'charset', + 'crossorigin', + 'decoding', + 'dir', + 'direction', + 'draggable', + 'enctype', + 'enterkeyhint', + 'fetchpriority', + 'formenctype', + 'formmethod', + 'formtarget', + 'hidden', + 'http-equiv', + 'inputmode', + 'kind', + 'loading', + 'method', + 'preload', + 'referrerpolicy', + 'rel', + 'rev', + 'role', + 'rules', + 'scope', + 'shape', + 'spellcheck', + 'target', + 'translate', + 'type', + 'valign', + 'wrap' +]); + /** @type {Compiler.AST.CSS.Combinator} */ const descendant_combinator = { type: 'Combinator', @@ -523,7 +567,9 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element, selector.name, selector.value && unquote(selector.value), selector.matcher, - selector.flags?.includes('i') ?? false + (selector.flags?.includes('i') ?? false) || + (!selector.flags?.includes('s') && + case_insensitive_attributes.has(selector.name.toLowerCase())) ) ) { return false; diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css new file mode 100644 index 0000000000..01ec1b2269 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css @@ -0,0 +1,11 @@ + form[method="get"].svelte-xyz h1:where(.svelte-xyz) { + color: red; + } + + form[method="post"].svelte-xyz h1:where(.svelte-xyz) { + color: blue; + } + + input[type="text"].svelte-xyz { + color: green; + } diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html new file mode 100644 index 0000000000..101c03f836 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html @@ -0,0 +1 @@ +

Hello

World

diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte new file mode 100644 index 0000000000..e11a922d75 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte @@ -0,0 +1,23 @@ +
+

Hello

+
+ +
+

World

+
+ + + + From e47c747338a99ae9699d9f13297140f145a34aec Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 09:57:06 -0500 Subject: [PATCH 3/8] fix: prevent event delegation logic conflicting between svelte instances (#17728) Fixes https://github.com/sveltejs/svelte.dev/issues/1793. There are actually two fixes here, and either is sufficient to fix the playground, but they are complementary. First, we only add the delegated event handler _after_ the component has successfully mounted, otherwise it will never get cleaned up if an error occurs during mount. Second, instead of storing data on `event.__root` (which leaks between instances), we reuse the existing `event_symbol` to provide the necessary encapsulation. (I'll be honest I don't totally understand what this property is for anyway and can't be bothered to figure it out right now, but I'm sure it's important.) No test because I'm not really sure how you _would_ test this; it requires a fairly esoteric setup. ### 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/chilly-kings-join.md | 5 ++ .../internal/client/dom/elements/events.js | 15 ++-- packages/svelte/src/internal/client/render.js | 85 ++++++++++--------- 3 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 .changeset/chilly-kings-join.md diff --git a/.changeset/chilly-kings-join.md b/.changeset/chilly-kings-join.md new file mode 100644 index 0000000000..b53d7e6cb6 --- /dev/null +++ b/.changeset/chilly-kings-join.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent event delegation logic conflicting between svelte instances diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 041698eb9d..e598a78949 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -11,8 +11,11 @@ import { set_active_reaction } from '../../runtime.js'; import { without_reactive_context } from './bindings/shared.js'; -import { can_delegate_event } from '../../../../utils.js'; +/** + * Used on elements, as a map of event type -> event handler, + * and on events themselves to track which element handled an event + */ export const event_symbol = Symbol('events'); /** @type {Set} */ @@ -177,8 +180,8 @@ export function handle_event_propagation(event) { last_propagated_event = event; // composedPath contains list of nodes the event has propagated through. - // We check __root to skip all nodes below it in case this is a - // parent of the __root node, which indicates that there's nested + // We check `event_symbol` to skip all nodes below it in case this is a + // parent of the `event_symbol` node, which indicates that there's nested // mounted apps. In this case we don't want to trigger events multiple times. var path_idx = 0; @@ -186,7 +189,7 @@ export function handle_event_propagation(event) { // without it the variable will be DCE'd and things will // fail mysteriously in Firefox // @ts-expect-error is added below - var handled_at = last_propagated_event === event && event.__root; + var handled_at = last_propagated_event === event && event[event_symbol]; if (handled_at) { var at_idx = path.indexOf(handled_at); @@ -198,7 +201,7 @@ export function handle_event_propagation(event) { // -> ignore, but set handle_at to document/window so that we're resetting the event // chain in case someone manually dispatches the same event object again. // @ts-expect-error - event.__root = handler_element; + event[event_symbol] = handler_element; return; } @@ -298,7 +301,7 @@ export function handle_event_propagation(event) { } } finally { // @ts-expect-error is used above - event.__root = handler_element; + event[event_symbol] = handler_element; // @ts-ignore remove proxy on currentTarget delete event.currentTarget; set_active_reaction(previous_reaction); diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 0d5bc6cb49..76a73852d5 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -161,48 +161,6 @@ const listeners = new Map(); function _mount(Component, { target, anchor, props = {}, events, context, intro = true }) { init_operations(); - /** @type {Set} */ - var registered_events = new Set(); - - /** @param {Array} events */ - var event_handle = (events) => { - for (var i = 0; i < events.length; i++) { - var event_name = events[i]; - - if (registered_events.has(event_name)) continue; - registered_events.add(event_name); - - var passive = is_passive_event(event_name); - - // Add the event listener to both the container and the document. - // The container listener ensures we catch events from within in case - // the outer content stops propagation of the event. - // - // The document listener ensures we catch events that originate from elements that were - // manually moved outside of the container (e.g. via manual portals). - for (const node of [target, document]) { - var counts = listeners.get(node); - - if (counts === undefined) { - counts = new Map(); - listeners.set(node, counts); - } - - var count = counts.get(event_name); - - if (count === undefined) { - node.addEventListener(event_name, handle_event_propagation, { passive }); - counts.set(event_name, 1); - } else { - counts.set(event_name, count + 1); - } - } - } - }; - - event_handle(array_from(all_registered_events)); - root_event_handles.add(event_handle); - /** @type {Exports} */ // @ts-expect-error will be defined because the render effect runs synchronously var component = undefined; @@ -251,6 +209,49 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro } ); + // Setup event delegation _after_ component is mounted - if an error would happen during mount, it would otherwise not be cleaned up + /** @type {Set} */ + var registered_events = new Set(); + + /** @param {Array} events */ + var event_handle = (events) => { + for (var i = 0; i < events.length; i++) { + var event_name = events[i]; + + if (registered_events.has(event_name)) continue; + registered_events.add(event_name); + + var passive = is_passive_event(event_name); + + // Add the event listener to both the container and the document. + // The container listener ensures we catch events from within in case + // the outer content stops propagation of the event. + // + // The document listener ensures we catch events that originate from elements that were + // manually moved outside of the container (e.g. via manual portals). + for (const node of [target, document]) { + var counts = listeners.get(node); + + if (counts === undefined) { + counts = new Map(); + listeners.set(node, counts); + } + + var count = counts.get(event_name); + + if (count === undefined) { + node.addEventListener(event_name, handle_event_propagation, { passive }); + counts.set(event_name, 1); + } else { + counts.set(event_name, count + 1); + } + } + } + }; + + event_handle(array_from(all_registered_events)); + root_event_handles.add(event_handle); + return () => { for (var event_name of registered_events) { for (const node of [target, document]) { From 04c0368aa8d8dff31cfe34bc134c02bfa6f05bf9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:04:16 -0500 Subject: [PATCH 4/8] Version Packages (#17725) 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.51.3 ### Patch Changes - fix: prevent event delegation logic conflicting between svelte instances ([#17728](https://github.com/sveltejs/svelte/pull/17728)) - fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes ([#17712](https://github.com/sveltejs/svelte/pull/17712)) - fix: locate Rollup annontaion friendly to JS downgraders ([#17724](https://github.com/sveltejs/svelte/pull/17724)) - fix: run effects in pending snippets ([#17719](https://github.com/sveltejs/svelte/pull/17719)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/chilly-kings-join.md | 5 ----- .changeset/css-attribute-case-insensitive.md | 5 ----- .changeset/icy-otters-fall.md | 5 ----- .changeset/many-dolls-argue.md | 5 ----- packages/svelte/CHANGELOG.md | 12 ++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 .changeset/chilly-kings-join.md delete mode 100644 .changeset/css-attribute-case-insensitive.md delete mode 100644 .changeset/icy-otters-fall.md delete mode 100644 .changeset/many-dolls-argue.md diff --git a/.changeset/chilly-kings-join.md b/.changeset/chilly-kings-join.md deleted file mode 100644 index b53d7e6cb6..0000000000 --- a/.changeset/chilly-kings-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: prevent event delegation logic conflicting between svelte instances diff --git a/.changeset/css-attribute-case-insensitive.md b/.changeset/css-attribute-case-insensitive.md deleted file mode 100644 index e5b3bcea2b..0000000000 --- a/.changeset/css-attribute-case-insensitive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes diff --git a/.changeset/icy-otters-fall.md b/.changeset/icy-otters-fall.md deleted file mode 100644 index b15c282ca4..0000000000 --- a/.changeset/icy-otters-fall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: locate Rollup annontaion friendly to JS downgraders diff --git a/.changeset/many-dolls-argue.md b/.changeset/many-dolls-argue.md deleted file mode 100644 index 54a9cef915..0000000000 --- a/.changeset/many-dolls-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: run effects in pending snippets diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 380de4b901..4e4f4f6088 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.51.3 + +### Patch Changes + +- fix: prevent event delegation logic conflicting between svelte instances ([#17728](https://github.com/sveltejs/svelte/pull/17728)) + +- fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes ([#17712](https://github.com/sveltejs/svelte/pull/17712)) + +- fix: locate Rollup annontaion friendly to JS downgraders ([#17724](https://github.com/sveltejs/svelte/pull/17724)) + +- fix: run effects in pending snippets ([#17719](https://github.com/sveltejs/svelte/pull/17719)) + ## 5.51.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index ee08526201..e9f4713d1c 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.51.2", + "version": "5.51.3", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index d6d42c7e97..c27b247dba 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.51.2'; +export const VERSION = '5.51.3'; export const PUBLIC_VERSION = '5'; From bd4ba56932cd364259c7dc64e3c1f1e8b3c10491 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 12:50:35 -0500 Subject: [PATCH 5/8] chore: make batch.is_deferred a private method (#17729) small tweak --- packages/svelte/src/internal/client/reactivity/batch.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b382a4e3a5..b5a2651b2a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -142,7 +142,7 @@ export class Batch { #decrement_queued = false; - is_deferred() { + #is_deferred() { return this.is_fork || this.#blocking_pending > 0; } @@ -202,7 +202,7 @@ export class Batch { // log_inconsistent_branches(root); } - if (this.is_deferred()) { + if (this.#is_deferred()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -475,7 +475,7 @@ export class Batch { queue_micro_task(() => { this.#decrement_queued = false; - if (!this.is_deferred()) { + if (!this.#is_deferred()) { // we only reschedule previously-deferred effects if we expect // to be able to run them after processing the batch this.revive(); From 3f6521df0e1c476b85f704b8aded0303be8eb4b4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 13:56:49 -0500 Subject: [PATCH 6/8] chore: unify async logic (#17731) We have a bunch of repeated logic around incrementing/decrementing pending states. This DRYs it out to unblock some forthcoming changes around scheduling --- .../src/internal/client/dom/blocks/async.js | 14 ++-------- .../internal/client/dom/blocks/boundary.js | 4 --- .../src/internal/client/reactivity/async.js | 28 +++++++++++-------- .../internal/client/reactivity/deriveds.js | 14 +++------- .../src/internal/client/reactivity/effects.js | 16 ++++++----- 5 files changed, 33 insertions(+), 43 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index e8c9cf0643..43af3d8dd3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,6 +1,5 @@ /** @import { Blocker, TemplateNode, Value } from '#client' */ -import { flatten } from '../../reactivity/async.js'; -import { Batch, current_batch } from '../../reactivity/batch.js'; +import { flatten, increment_pending } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -10,7 +9,6 @@ import { set_hydrating, skip_nodes } from '../hydration.js'; -import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node @@ -44,12 +42,7 @@ export function async(node, blockers = [], expressions = [], fn) { return; } - var boundary = get_boundary(); - var batch = /** @type {Batch} */ (current_batch); - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + const decrement_pending = increment_pending(); if (was_hydrating) { var previous_hydrate_node = hydrate_node; @@ -72,8 +65,7 @@ export function async(node, blockers = [], expressions = [], fn) { set_hydrating(false); } - boundary.update_pending_count(-1); - batch.decrement(blocking); + decrement_pending(); } }); } diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index da70cbb19d..08cc994494 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -452,10 +452,6 @@ export class Boundary { } } -export function get_boundary() { - return /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); -} - export function pending() { if (active_effect === null) { e.effect_pending_outside_reaction(); diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index b6eba3bf8a..b3c5248179 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -8,7 +8,7 @@ import { set_component_context, set_dev_stack } from '../context.js'; -import { get_boundary } from '../dom/blocks/boundary.js'; +import { Boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -224,12 +224,7 @@ export function unset_context() { export function run(thunks) { const restore = capture(); - var boundary = get_boundary(); - var batch = /** @type {Batch} */ (current_batch); - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + const decrement_pending = increment_pending(); var active = /** @type {Effect} */ (active_effect); @@ -286,10 +281,7 @@ export function run(thunks) { // wait one more tick, so that template effects are // guaranteed to run before `$effect(...)` .then(() => Promise.resolve()) - .finally(() => { - boundary.update_pending_count(-1); - batch.decrement(blocking); - }); + .finally(decrement_pending); return blockers; } @@ -300,3 +292,17 @@ export function run(thunks) { export function wait(blockers) { return Promise.all(blockers.map((b) => b.promise)); } + +export function increment_pending() { + var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); + var batch = /** @type {Batch} */ (current_batch); + var blocking = boundary.is_rendered(); + + boundary.update_pending_count(1); + batch.increment(blocking); + + return () => { + boundary.update_pending_count(-1); + batch.decrement(blocking); + }; +} diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index d11854fc91..80da5528c8 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -40,7 +40,7 @@ import { Boundary } from '../dom/blocks/boundary.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; import { batch_values, current_batch } from './batch.js'; -import { unset_context } from './async.js'; +import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; @@ -111,8 +111,6 @@ export function async_derived(fn, label, location) { e.async_derived_orphan(); } - var boundary = /** @type {Boundary} */ (parent.b); - var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); @@ -156,10 +154,7 @@ export function async_derived(fn, label, location) { var batch = /** @type {Batch} */ (current_batch); if (should_suspend) { - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + var decrement_pending = increment_pending(); deferreds.get(batch)?.reject(STALE_REACTION); deferreds.delete(batch); // delete to ensure correct order in Map iteration below @@ -208,9 +203,8 @@ export function async_derived(fn, label, location) { } } - if (should_suspend) { - boundary.update_pending_count(-1); - batch.decrement(blocking); + if (decrement_pending) { + decrement_pending(); } }; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 512c435a27..157587e218 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -40,8 +40,8 @@ import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; -import { Batch, current_batch, schedule_effect } from './batch.js'; -import { flatten } from './async.js'; +import { Batch, 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'; @@ -376,14 +376,16 @@ export function template_effect(fn, sync = [], async = [], blockers = []) { * @param {Blocker[]} blockers */ export function deferred_template_effect(fn, sync = [], async = [], blockers = []) { - var batch = /** @type {Batch} */ (current_batch); - var is_async = async.length > 0 || blockers.length > 0; - - if (is_async) batch.increment(true); + if (async.length > 0 || blockers.length > 0) { + var decrement_pending = increment_pending(); + } flatten(blockers, sync, async, (values) => { create_effect(EFFECT, () => fn(...values.map(get)), false); - if (is_async) batch.decrement(true); + + if (decrement_pending) { + decrement_pending(); + } }); } From 2287ad005aee7fc14681f3913c3f5a0cebfd181e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 16:38:12 -0500 Subject: [PATCH 7/8] fix: detect and error on non-idempotent each block keys in dev mode (#17732) ## Summary Fixes #17721 In dev mode, detect when a keyed each block has a key function that returns different values when called multiple times for the same item (non-idempotent). This catches the common mistake of using array literals like `[thing.group, thing.id]` as keys, which creates a new array object each time and will never match by reference. - Adds new `each_key_volatile` error with helpful message explaining the issue - Checks key idempotency in the each block loop during dev mode - Provides a clear error instead of the cryptic "Cannot read properties of undefined" that occurred previously --------- Co-authored-by: 7nik --- .changeset/volatile-each-key.md | 5 +++++ .../98-reference/.generated/client-errors.md | 8 ++++++++ .../svelte/messages/client-errors/errors.md | 6 ++++++ .../src/internal/client/dom/blocks/each.js | 8 ++++++++ packages/svelte/src/internal/client/errors.js | 19 +++++++++++++++++++ .../samples/each-key-volatile/_config.js | 11 +++++++++++ .../samples/each-key-volatile/main.svelte | 10 ++++++++++ 7 files changed, 67 insertions(+) create mode 100644 .changeset/volatile-each-key.md create mode 100644 packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte diff --git a/.changeset/volatile-each-key.md b/.changeset/volatile-each-key.md new file mode 100644 index 0000000000..674bce9bec --- /dev/null +++ b/.changeset/volatile-each-key.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: detect and error on non-idempotent each block keys in dev mode diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 8601a728a7..7fccac5808 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -62,6 +62,14 @@ Keyed each block has duplicate key at indexes %a% and %b% Keyed each block has duplicate key `%value%` at indexes %a% and %b% ``` +### each_key_volatile + +``` +Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item +``` + +The key expression in a keyed each block must return the same value when called multiple times for the same item. Using expressions like `[item.a, item.b]` creates a new array each time, which will never be equal to itself. Instead, use a primitive value or create a stable key like `item.a + '-' + item.b`. + ### effect_in_teardown ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index bedf6db0a5..3f20cb989d 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -42,6 +42,12 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long > Keyed each block has duplicate key `%value%` at indexes %a% and %b% +## each_key_volatile + +> Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item + +The key expression in a keyed each block must return the same value when called multiple times for the same item. Using expressions like `[item.a, item.b]` creates a new array each time, which will never be equal to itself. Instead, use a primitive value or create a stable key like `item.a + '-' + item.b`. + ## effect_in_teardown > `%rune%` cannot be used inside an effect cleanup function diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 25f7cf91eb..7ae02d073c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -250,6 +250,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var value = array[index]; var key = get_key(value, index); + if (DEV) { + // Check that the key function is idempotent (returns the same value when called twice) + var key_again = get_key(value, index); + if (key !== key_again) { + e.each_key_volatile(String(index), String(key), String(key_again)); + } + } + var item = first_run ? null : items.get(key); if (item) { diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 34f1d85540..d60c2dd280 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -147,6 +147,25 @@ export function each_key_duplicate(a, b, value) { } } +/** + * Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item + * @param {string} index + * @param {string} a + * @param {string} b + * @returns {never} + */ +export function each_key_volatile(index, a, b) { + if (DEV) { + const error = new Error(`each_key_volatile\nKeyed each block has key that is not idempotent — the key for item at index ${index} was \`${a}\` but is now \`${b}\`. Keys must be the same each time for a given item\nhttps://svelte.dev/e/each_key_volatile`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/each_key_volatile`); + } +} + /** * `%rune%` cannot be used inside an effect cleanup function * @param {string} rune diff --git a/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js new file mode 100644 index 0000000000..87d4bf45c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js @@ -0,0 +1,11 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + mode: ['client'], + + error: 'each_key_volatile' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte new file mode 100644 index 0000000000..689c257cfa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte @@ -0,0 +1,10 @@ + + +{#each things as thing ([thing.group, thing.id])} +

{thing.group}-{thing.id}

+{/each} From c83aa06d69fba0f30300a9f0614e00030fbfbc31 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 17:38:05 -0500 Subject: [PATCH 8/8] chore: proactively defer effects in pending boundary (#17734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, (render/template) effects inside pending boundaries are deferred, but in an indirect manner: first we schedule them, then we `flush` the current batch, and in the course of traversing the effect tree we find any dirty effects and defer them at the level of the topmost pending boundary. This doesn't really make sense — we can just skip to the end state and skip the scheduling/traversal, since the effects don't become relevant until the boundary resolves. This PR implements that. It is a stepping stone towards a larger refactor, in which scheduling becomes batch-centric and lazier. While it shouldn't change any observable behaviour, I've added a changeset out of an abundance of caution. ### 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/angry-ideas-listen.md | 5 ++ .../internal/client/dom/blocks/boundary.js | 44 +++++++++-------- .../src/internal/client/reactivity/batch.js | 49 +++++++++---------- 3 files changed, 50 insertions(+), 48 deletions(-) create mode 100644 .changeset/angry-ideas-listen.md diff --git a/.changeset/angry-ideas-listen.md b/.changeset/angry-ideas-listen.md new file mode 100644 index 0000000000..cb2bf00d1c --- /dev/null +++ b/.changeset/angry-ideas-listen.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: proactively defer effects in pending boundary diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 08cc994494..8f23fb1a2e 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,7 +1,6 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ import { BOUNDARY_EFFECT, - COMMENT_NODE, DIRTY, EFFECT_PRESERVED, EFFECT_TRANSPARENT, @@ -202,7 +201,7 @@ export class Boundary { this.#pending_effect = null; }); - this.is_pending = false; + this.#resolve(); } }); } @@ -224,13 +223,33 @@ export class Boundary { const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); this.#pending_effect = branch(() => pending(this.#anchor)); } else { - this.is_pending = false; + this.#resolve(); } } catch (error) { this.error(error); } } + #resolve() { + this.is_pending = false; + + // any effects that were previously deferred should be rescheduled — + // after the next traversal (which will happen immediately, due to the + // 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); + } + + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + schedule_effect(e); + } + + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); + } + /** * Defer an effect inside a pending boundary until the boundary resolves * @param {Effect} effect @@ -294,24 +313,7 @@ export class Boundary { this.#pending_count += d; if (this.#pending_count === 0) { - this.is_pending = false; - - // any effects that were encountered and deferred during traversal - // should be rescheduled — after the next traversal (which will happen - // immediately, due to the 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); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - schedule_effect(e); - } - - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + this.#resolve(); if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b5a2651b2a..297049fd6b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -18,7 +18,8 @@ import { EAGER_EFFECT, HEAD_EFFECT, ERROR_VALUE, - MANAGED_EFFECT + MANAGED_EFFECT, + REACTION_RAN } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property, includes } from '../../shared/utils.js'; @@ -246,9 +247,6 @@ export class Batch { var effect = root.first; - /** @type {Effect | null} */ - var pending_boundary = null; - while (effect !== null) { var flags = effect.f; var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; @@ -256,26 +254,9 @@ export class Batch { var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect); - // Inside a `` with a pending snippet, - // all effects are deferred until the boundary resolves - // (except block/async effects, which run immediately) - if ( - async_mode_flag && - pending_boundary === null && - (flags & BOUNDARY_EFFECT) !== 0 && - effect.b?.is_pending - ) { - pending_boundary = effect; - } - if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; - } else if ( - pending_boundary !== null && - (flags & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 - ) { - /** @type {Boundary} */ (pending_boundary.b).defer_effect(effect); } else if ((flags & EFFECT) !== 0) { effects.push(effect); } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { @@ -294,10 +275,6 @@ export class Batch { } while (effect !== null) { - if (effect === pending_boundary) { - pending_boundary = null; - } - var next = effect.next; if (next !== null) { @@ -839,6 +816,19 @@ function depends_on(reaction, sources, checked) { export function schedule_effect(signal) { var effect = (last_scheduled_effect = signal); + var boundary = effect.b; + + // defer render effects inside a pending boundary + // TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later + if ( + boundary?.is_pending && + (signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 && + (signal.f & REACTION_RAN) === 0 + ) { + boundary.defer_effect(signal); + return; + } + while (effect.parent !== null) { effect = effect.parent; var flags = effect.f; @@ -850,13 +840,18 @@ export function schedule_effect(signal) { is_flushing && effect === active_effect && (flags & BLOCK_EFFECT) !== 0 && - (flags & HEAD_EFFECT) === 0 + (flags & HEAD_EFFECT) === 0 && + (flags & REACTION_RAN) !== 0 ) { return; } if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { - if ((flags & CLEAN) === 0) return; + if ((flags & CLEAN) === 0) { + // branch is already dirty, bail + return; + } + effect.f ^= CLEAN; } }