From 2b740e496e857ae9e53bd15f5d5d7a2d4398139b Mon Sep 17 00:00:00 2001 From: Nic Polumeyv Date: Tue, 25 Aug 2026 04:14:43 -0400 Subject: [PATCH 01/29] fix: commit a reaction's dependencies even when it throws (#18703) Fixes #18414 and a second bug with the same cause. `update_reaction` in `runtime.js` commits a reaction's dependencies after `fn()` returns: it swaps in `new_deps`, removes stale reactions and registers the reaction into each new dep's `reactions`. When `fn()` throws, the whole block is skipped. Two things go wrong from that. A derived that the run read for the first time was already set `CONNECTED` in `get` and registered itself into its own deps' `reactions` during `update_derived`, but the throwing reaction never registers as its reader. It sits in its deps' `reactions` with `reactions === null` of its own, `remove_reaction`'s disconnect cascade can never reach it, and it retains its `ctx`, closures and DOM past component destruction and `unmount()`. A derived that throws during its own run ends up with no dependencies at all. It is not in its sources' `reactions`, so a later change to them never re-runs it, and for an unowned derived `update_derived` then marks the error value CLEAN because the derived has no deps. Anything reading it keeps getting the first error forever. The commit block is now `update_dependencies` and the `catch` calls it before `handle_error`, so a failing reaction keeps the deps it read up to the throw. That subscribes it to the inputs that produced the error, and lets the normal disconnect cascade clean up when it is destroyed (`Boundary.#handle_error` destroys the failed effect). Co-authored-by: Randy Murphy --- .changeset/quiet-deriveds-unwind.md | 5 ++ .../svelte/src/internal/client/runtime.js | 76 +++++++++++-------- packages/svelte/tests/signals/test.ts | 70 +++++++++++++++++ 3 files changed, 119 insertions(+), 32 deletions(-) create mode 100644 .changeset/quiet-deriveds-unwind.md diff --git a/.changeset/quiet-deriveds-unwind.md b/.changeset/quiet-deriveds-unwind.md new file mode 100644 index 0000000000..8036bdb781 --- /dev/null +++ b/.changeset/quiet-deriveds-unwind.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep the dependencies of a reaction that throws, so deriveds it read are neither leaked nor stuck in their error diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index e0914b8f70..a881403f58 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -258,37 +258,7 @@ export function update_reaction(reaction) { var fn = /** @type {Function} */ (reaction.fn); var result = fn(); reaction.f |= REACTION_RAN; - var deps = reaction.deps; - - // Don't remove reactions during fork; - // they must remain for when fork is discarded - var is_fork = current_batch?.is_fork; - - if (new_deps !== null) { - var i; - - if (!is_fork) { - remove_reactions(reaction, skipped_deps); - } - - if (deps !== null && skipped_deps > 0) { - deps.length = skipped_deps + new_deps.length; - for (i = 0; i < new_deps.length; i++) { - deps[skipped_deps + i] = new_deps[i]; - } - } else { - reaction.deps = deps = new_deps; - } - - if (effect_tracking() && (reaction.f & CONNECTED) !== 0) { - for (i = skipped_deps; i < deps.length; i++) { - (deps[i].reactions ??= []).push(reaction); - } - } - } else if (!is_fork && deps !== null && skipped_deps < deps.length) { - remove_reactions(reaction, skipped_deps); - deps.length = skipped_deps; - } + var deps = update_dependencies(reaction); // If we're inside an effect and we have untracked writes, then we need to // ensure that if any of those untracked writes result in re-invalidation @@ -300,7 +270,7 @@ export function update_reaction(reaction) { deps !== null && (reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0 ) { - for (i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) { + for (var i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) { schedule_possible_effect_self_invalidation( untracked_writes[i], /** @type {Effect} */ (reaction) @@ -344,6 +314,9 @@ export function update_reaction(reaction) { return result; } catch (error) { + // still commit the deps read before the throw, otherwise deriveds connected by this run keep no reader and the reaction never re-runs when they change + update_dependencies(reaction); + return handle_error(error); } finally { reaction.f ^= REACTION_IS_UPDATING; @@ -358,6 +331,45 @@ export function update_reaction(reaction) { } } +/** + * @param {Reaction} reaction + */ +function update_dependencies(reaction) { + var deps = reaction.deps; + + // Don't remove reactions during fork; + // they must remain for when fork is discarded + var is_fork = current_batch?.is_fork; + + if (new_deps !== null) { + var i; + + if (!is_fork) { + remove_reactions(reaction, skipped_deps); + } + + if (deps !== null && skipped_deps > 0) { + deps.length = skipped_deps + new_deps.length; + for (i = 0; i < new_deps.length; i++) { + deps[skipped_deps + i] = new_deps[i]; + } + } else { + reaction.deps = deps = new_deps; + } + + if (effect_tracking() && (reaction.f & CONNECTED) !== 0) { + for (i = skipped_deps; i < deps.length; i++) { + (deps[i].reactions ??= []).push(reaction); + } + } + } else if (!is_fork && deps !== null && skipped_deps < deps.length) { + remove_reactions(reaction, skipped_deps); + deps.length = skipped_deps; + } + + return deps; +} + /** * @template V * @param {Reaction} signal diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 07f79bd395..c1ebc3ec75 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1521,4 +1521,74 @@ describe('signals', () => { assert.equal(s.reactions, null); }; }); + + // https://github.com/sveltejs/svelte/issues/18414 + test('a reaction that throws after first-reading a fresh derived does not leak it', () => { + const src = state(0); + const pane = derived(() => $.get(src)); + const base = derived(() => $.get(pane) + ':base'); + const extra = derived(() => $.get(pane) + ':extra'); + const flag = state(false); + + const destroy = effect_root(() => { + render_effect(() => { + if ($.get(flag)) { + $.get(extra); + throw new Error('render boom'); + } else { + $.get(base); + } + }); + }); + + return () => { + try { + flushSync(() => set(flag, true)); + } catch {} + + destroy(); + + assert.equal(src.reactions, null); + }; + }); + + test('a derived that throws on its first run re-runs when its dependencies change', () => { + const s = state(0); + const fn = () => { + if ($.get(s) === 0) throw new Error('boom'); + return $.get(s); + }; + const owned = derived(fn); + const previous_effect = $.active_effect; + $.set_active_effect(null); + const unowned = derived(fn); + $.set_active_effect(previous_effect); + const log: any[] = []; + + const destroy = effect_root(() => { + render_effect(() => { + for (const d of [owned, unowned]) { + try { + log.push($.get(d)); + } catch { + log.push('error'); + } + } + }); + }); + + return () => { + assert.notEqual(owned.parent, null); + assert.equal(unowned.parent, null); + + flushSync(); + assert.deepEqual(log, ['error', 'error']); + + flushSync(() => set(s, 1)); + assert.deepEqual(log, ['error', 'error', 1, 1]); + + destroy(); + assert.equal(s.reactions, null); + }; + }); }); From 1b02aa28c8d746280617f59d6126b8b60f6cd748 Mon Sep 17 00:00:00 2001 From: Jawad Ali Date: Tue, 25 Aug 2026 13:28:46 +0500 Subject: [PATCH 02/29] fix: decode uppercase-X hex numeric character references (#18708) uppercase X is also valid per the HTML spec --- .changeset/uppercase-hex-entities.md | 5 +++++ packages/svelte/src/compiler/phases/1-parse/utils/html.js | 4 ++-- .../tests/runtime-legacy/samples/html-entities/_config.js | 1 + .../tests/runtime-legacy/samples/html-entities/main.svelte | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/uppercase-hex-entities.md diff --git a/.changeset/uppercase-hex-entities.md b/.changeset/uppercase-hex-entities.md new file mode 100644 index 0000000000..e6e13d5df5 --- /dev/null +++ b/.changeset/uppercase-hex-entities.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: decode uppercase-`X` hex numeric character references (`&#X...;`) diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/html.js b/packages/svelte/src/compiler/phases/1-parse/utils/html.js index a0c2a5b06f..1d660298c7 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/html.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/html.js @@ -20,7 +20,7 @@ function reg_exp_entity(entity_name, is_attribute_value) { /** @param {boolean} is_attribute_value */ function get_entity_pattern(is_attribute_value) { - const reg_exp_num = '#(?:x[a-fA-F\\d]+|\\d+)(?:;)?'; + const reg_exp_num = '#(?:[xX][a-fA-F\\d]+|\\d+)(?:;)?'; const reg_exp_entities = Object.keys(entities).map( /** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value) ); @@ -50,7 +50,7 @@ export function decode_character_references(html, is_attribute_value) { // Handle named entities if (entity[0] !== '#') { code = entities[entity]; - } else if (entity[1] === 'x') { + } else if (entity[1] === 'x' || entity[1] === 'X') { code = parseInt(entity.substring(2), 16); } else { code = parseInt(entity.substring(1), 10); diff --git a/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js b/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js index add763989e..82c87495c4 100644 --- a/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js @@ -7,6 +7,7 @@ export default test({ * * * + * A diff --git a/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte b/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte index 2d0d66c939..25a60cb930 100644 --- a/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte @@ -2,6 +2,7 @@ * * * +* * From 6b16b7f73fe8223ad7ab8bc629c98ba6d581f5cf Mon Sep 17 00:00:00 2001 From: Nic Polumeyv Date: Tue, 25 Aug 2026 04:35:46 -0400 Subject: [PATCH 03/29] fix: keep defaultChecked on hydrated radio inputs with spread attributes (#18701) radio buttons were missing in the type check - remove the check instead (aligns with how we do it in the compiler, see `has_default_value_attribute`). Strictly speaking not fully correct but pragmatic solution that doesn't have any impact in practise Needed by sveltejs/kit#16926. --- .changeset/keep-radio-default-checked.md | 5 ++++ .../client/dom/elements/attributes.js | 7 ++---- .../_config.js | 23 +++++++++++++++++++ .../main.svelte | 9 ++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 .changeset/keep-radio-default-checked.md create mode 100644 packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/main.svelte diff --git a/.changeset/keep-radio-default-checked.md b/.changeset/keep-radio-default-checked.md new file mode 100644 index 0000000000..b3b32afda6 --- /dev/null +++ b/.changeset/keep-radio-default-checked.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep `defaultChecked` on hydrated radio inputs with spread attributes diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 3d71abcd10..69d4dd42a8 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -291,11 +291,8 @@ function set_attributes( skip_warning = false ) { if (hydrating && should_remove_defaults && element.nodeName === INPUT_TAG) { - var input = /** @type {HTMLInputElement} */ (element); - var attribute = input.type === 'checkbox' ? 'defaultChecked' : 'defaultValue'; - - if (!(attribute in next)) { - remove_input_defaults(input); + if (!('defaultValue' in next || 'defaultChecked' in next)) { + remove_input_defaults(/** @type {HTMLInputElement} */ (element)); } } diff --git a/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/_config.js new file mode 100644 index 0000000000..3f3134ee7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/_config.js @@ -0,0 +1,23 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + + async test({ assert, target }) { + const [a, b, reset] = target.querySelectorAll('input'); + + // let the deferred hydration cleanup run + await Promise.resolve(); + flushSync(); + + b.checked = true; + reset.click(); + await Promise.resolve(); + flushSync(); + + assert.equal(a.defaultChecked, true); + assert.equal(a.checked, true); + assert.equal(b.checked, false); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/main.svelte new file mode 100644 index 0000000000..cb7ff0abd3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/form-default-checked-radio-spread/main.svelte @@ -0,0 +1,9 @@ + + +
+ + + +
From c894afd8c73e4f08a377909f29aa58177a256277 Mon Sep 17 00:00:00 2001 From: Jawad Ali Date: Tue, 25 Aug 2026 13:37:34 +0500 Subject: [PATCH 04/29] fix: sync SvelteURL port when protocol setter clears it (#18705) Per the WHATWG URL spec, assigning a new protocol can clear the URL's port when the current port equals the new scheme's default port. Therefore also `set` the port when the protocol is updated. --- .changeset/svelte-url-protocol-port.md | 5 +++++ packages/svelte/src/reactivity/url.js | 2 ++ packages/svelte/src/reactivity/url.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 .changeset/svelte-url-protocol-port.md diff --git a/.changeset/svelte-url-protocol-port.md b/.changeset/svelte-url-protocol-port.md new file mode 100644 index 0000000000..2c37fce4ab --- /dev/null +++ b/.changeset/svelte-url-protocol-port.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: sync `SvelteURL` port signal when the protocol setter clears the port diff --git a/packages/svelte/src/reactivity/url.js b/packages/svelte/src/reactivity/url.js index 549a20baa7..1e24e774ee 100644 --- a/packages/svelte/src/reactivity/url.js +++ b/packages/svelte/src/reactivity/url.js @@ -163,6 +163,8 @@ export class SvelteURL extends URL { set protocol(value) { super.protocol = value; set(this.#protocol, super.protocol); + // changing the protocol can clear the port when it matches the new scheme's default + set(this.#port, super.port); } get search() { diff --git a/packages/svelte/src/reactivity/url.test.ts b/packages/svelte/src/reactivity/url.test.ts index d698116421..72518d730a 100644 --- a/packages/svelte/src/reactivity/url.test.ts +++ b/packages/svelte/src/reactivity/url.test.ts @@ -240,3 +240,25 @@ test('url.searchParams.forEach re-runs when the search string changes via the UR cleanup(); }); + +test('url.port is updated when the protocol change clears the port', () => { + const url = new SvelteURL('http://example.com:443/'); + const log: any = []; + + const cleanup = effect_root(() => { + render_effect(() => { + log.push(url.port); + }); + }); + + flushSync(() => { + // 443 is the default port for https, so it gets stripped + url.protocol = 'https:'; + }); + + assert.equal(url.port, ''); + assert.equal(url.href, 'https://example.com/'); + assert.deepEqual(log, ['443', '']); + + cleanup(); +}); From 135f1ec004352048454e732202c0bc8332816242 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:24:45 +0200 Subject: [PATCH 05/29] fix: more robust rendering of Svelte custom element slots (#18710) Instead of checking the given nodes at startup and only injecting those into the inner Svelte component constructor, we are now detecting (via the `$$host` property) whether or not the component is rendered as a custom element, and in that case create all slots as real slots right away. Fixes #13638 Fixes #8997 (therefore closes #8999) --- .changeset/open-parents-stare.md | 5 ++++ .../src/internal/client/dom/blocks/slot.js | 19 +++++++++++++ .../$$slot-dynamic-content/_config.js | 8 +++--- .../html-slots/_config.js | 28 +++++++++++++++---- .../no-shadow-dom/_config.js | 3 +- .../no-shadow-dom/main.svelte | 1 + 6 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 .changeset/open-parents-stare.md diff --git a/.changeset/open-parents-stare.md b/.changeset/open-parents-stare.md new file mode 100644 index 0000000000..cb94267ea0 --- /dev/null +++ b/.changeset/open-parents-stare.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more robust rendering of Svelte custom element slots diff --git a/packages/svelte/src/internal/client/dom/blocks/slot.js b/packages/svelte/src/internal/client/dom/blocks/slot.js index 10b2959582..491f04a5ea 100644 --- a/packages/svelte/src/internal/client/dom/blocks/slot.js +++ b/packages/svelte/src/internal/client/dom/blocks/slot.js @@ -1,4 +1,6 @@ import { hydrate_next, hydrating } from '../hydration.js'; +import { create_element, create_text } from '../operations.js'; +import { append } from '../template.js'; /** * @param {Comment} anchor @@ -12,6 +14,23 @@ export function slot(anchor, $$props, name, slot_props, fallback_fn) { hydrate_next(); } + // Custom element slots are native DOM slots. + // Use the stored reference because the shadow root may be closed. + if ($$props.$$host?.$$shadowRoot) { + const element = create_element('slot'); + if (name !== 'default') element.name = name; + + append(anchor, element); + + if (fallback_fn !== null) { + const fallback_anchor = create_text(); + element.append(fallback_anchor); + fallback_fn(fallback_anchor); + } + + return; + } + var slot_fn = $$props.$$slots?.[name]; // Interop: Can use snippets to fill slots var is_interop = false; diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js index 8ade347a79..3fd5a09888 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js @@ -14,8 +14,8 @@ export default test({ assert.htmlEqual( ce.shadowRoot.innerHTML, ` - -

named fallback

+ fallback +

named fallback

` ); @@ -23,8 +23,8 @@ export default test({ assert.htmlEqual( ce.shadowRoot.innerHTML, ` - -

named fallback

+ fallback +

named fallback

` ); } diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js index 287e2987b8..e3e8ce072b 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js @@ -3,10 +3,7 @@ const tick = () => Promise.resolve(); export default test({ async test({ assert, target }) { - target.innerHTML = ` - - slotted - `; + target.innerHTML = ''; await tick(); await tick(); @@ -16,7 +13,26 @@ export default test({ const div = el.shadowRoot.children[0]; const [slot0, slot1] = div.children; - assert.equal(slot0.assignedNodes()[1], target.querySelector('strong')); - assert.equal(slot1.innerHTML, 'foo fallback content'); + assert.equal(slot0.localName, 'slot'); + assert.equal(slot0.assignedNodes().length, 0); + assert.equal(slot0.innerHTML, '

default fallback content

'); + assert.equal(slot1.localName, 'slot'); + assert.equal(slot1.name, 'foo'); + assert.equal(slot1.assignedNodes().length, 0); + assert.equal(slot1.innerHTML, '

foo fallback content

'); + + const default_content = document.createElement('strong'); + default_content.textContent = 'default content'; + el.append(default_content); + + const named_content = document.createElement('strong'); + named_content.slot = 'foo'; + named_content.textContent = 'named content'; + el.append(named_content); + + assert.equal(slot0.assignedNodes().length, 1); + assert.equal(slot0.assignedNodes()[0], default_content); + assert.equal(slot1.assignedNodes().length, 1); + assert.equal(slot1.assignedNodes()[0], named_content); } }); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js index cd8550120a..e83491418a 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js @@ -3,7 +3,7 @@ const tick = () => Promise.resolve(); export default test({ async test({ assert, target }) { - target.innerHTML = ''; + target.innerHTML = 'slotted'; await tick(); await tick(); @@ -15,5 +15,6 @@ export default test({ assert.equal(el.shadowRoot, null); assert.equal(h1.innerHTML, 'Hello world!'); assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)'); + assert.equal(el.querySelector('slot').innerHTML, ''); } }); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte index 36fea3c43e..1c14fb98d8 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte @@ -5,6 +5,7 @@

Hello {name}!

+fallback