From 3f6521df0e1c476b85f704b8aded0303be8eb4b4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Feb 2026 13:56:49 -0500 Subject: [PATCH 01/54] 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 02/54] 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 03/54] 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; } } From b8f2b86105e02c03a884dfef184e0e1b89471b55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:50:48 -0700 Subject: [PATCH 04/54] Version Packages (#17733) 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.4 ### Patch Changes - chore: proactively defer effects in pending boundary ([#17734](https://github.com/sveltejs/svelte/pull/17734)) - fix: detect and error on non-idempotent each block keys in dev mode ([#17732](https://github.com/sveltejs/svelte/pull/17732)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/angry-ideas-listen.md | 5 ----- .changeset/volatile-each-key.md | 5 ----- packages/svelte/CHANGELOG.md | 8 ++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 .changeset/angry-ideas-listen.md delete mode 100644 .changeset/volatile-each-key.md diff --git a/.changeset/angry-ideas-listen.md b/.changeset/angry-ideas-listen.md deleted file mode 100644 index cb2bf00d1c..0000000000 --- a/.changeset/angry-ideas-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: proactively defer effects in pending boundary diff --git a/.changeset/volatile-each-key.md b/.changeset/volatile-each-key.md deleted file mode 100644 index 674bce9bec..0000000000 --- a/.changeset/volatile-each-key.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: detect and error on non-idempotent each block keys in dev mode diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 4e4f4f6088..9b2a9c1ac7 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # svelte +## 5.51.4 + +### Patch Changes + +- chore: proactively defer effects in pending boundary ([#17734](https://github.com/sveltejs/svelte/pull/17734)) + +- fix: detect and error on non-idempotent each block keys in dev mode ([#17732](https://github.com/sveltejs/svelte/pull/17732)) + ## 5.51.3 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index e9f4713d1c..05493c6591 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.3", + "version": "5.51.4", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index c27b247dba..c2d6ca81fb 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.3'; +export const VERSION = '5.51.4'; export const PUBLIC_VERSION = '5'; From f89c7ddd7eebaa1ef3cc540400bec2c9140b330c Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 18 Feb 2026 10:53:42 -0700 Subject: [PATCH 05/54] Merge commit from fork Co-authored-by: Rich Harris --- .changeset/elliott-elliott-elliott.md | 5 +++++ packages/svelte/src/internal/server/index.js | 13 +++++++------ packages/svelte/src/internal/server/renderer.js | 2 +- packages/svelte/src/internal/shared/attributes.js | 6 +++--- packages/svelte/src/internal/shared/clone.js | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 .changeset/elliott-elliott-elliott.md diff --git a/.changeset/elliott-elliott-elliott.md b/.changeset/elliott-elliott-elliott.md new file mode 100644 index 0000000000..cf2e2e1cc1 --- /dev/null +++ b/.changeset/elliott-elliott-elliott.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: replace usage of `for in` with `for of Object.keys` diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 015db09a64..d978ed6355 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -138,7 +138,7 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) { const lowercase = (flags & ELEMENT_PRESERVE_ATTRIBUTE_CASE) === 0; const is_input = (flags & ELEMENT_IS_INPUT) !== 0; - for (name in attrs) { + for (name of Object.keys(attrs)) { // omit functions, internal svelte properties and invalid attribute names if (typeof attrs[name] === 'function') continue; if (name[0] === '$' && name[1] === '$') continue; // faster than name.startsWith('$$') @@ -174,7 +174,8 @@ export function spread_props(props) { for (let i = 0; i < props.length; i++) { const obj = props[i]; - for (key in obj) { + if (obj == null) continue; + for (key of Object.keys(obj)) { const desc = Object.getOwnPropertyDescriptor(obj, key); if (desc) { Object.defineProperty(merged_props, key, desc); @@ -302,7 +303,7 @@ export function update_store_pre(store_values, store_name, store, d = 1) { /** @param {Record} store_values */ export function unsubscribe_stores(store_values) { - for (const store_name in store_values) { + for (const store_name of Object.keys(store_values)) { store_values[store_name][1](); } } @@ -338,7 +339,7 @@ export function rest_props(props, rest) { /** @type {Record} */ const rest_props = {}; let key; - for (key in props) { + for (key of Object.keys(props)) { if (!rest.includes(key)) { rest_props[key] = props[key]; } @@ -363,7 +364,7 @@ export function sanitize_slots(props) { /** @type {Record} */ const sanitized = {}; if (props.children) sanitized.default = true; - for (const key in props.$$slots) { + for (const key of Object.keys(props.$$slots || {})) { sanitized[key] = true; } return sanitized; @@ -376,7 +377,7 @@ export function sanitize_slots(props) { * @param {Record} props_now */ export function bind_props(props_parent, props_now) { - for (const key in props_now) { + for (const key of Object.keys(props_now)) { const initial_value = props_parent[key]; const value = props_now[key]; if ( diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index d18a48bdb4..9df914b35a 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -267,7 +267,7 @@ export class Renderer { * @param {{ head?: string, body: any }} content */ const close = (renderer, value, { head, body }) => { - if ('value' in attrs) { + if (Object.hasOwn(attrs, 'value')) { value = attrs.value; } diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js index 8bfa91f80c..21bfd3d5a8 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.js @@ -27,7 +27,7 @@ export function attr(name, value, is_boolean = false) { is_boolean = true; } if (value == null || (!value && is_boolean)) return ''; - const normalized = (name in replacements && replacements[name].get(value)) || value; + const normalized = (Object.hasOwn(replacements, name) && replacements[name].get(value)) || value; const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`; return ` ${name}${assignment}`; } @@ -61,7 +61,7 @@ export function to_class(value, hash, directives) { } if (directives) { - for (var key in directives) { + for (var key of Object.keys(directives)) { if (directives[key]) { classname = classname ? classname + ' ' + key : key; } else if (classname.length) { @@ -96,7 +96,7 @@ function append_styles(styles, important = false) { var separator = important ? ' !important;' : ';'; var css = ''; - for (var key in styles) { + for (var key of Object.keys(styles)) { var value = styles[key]; if (value != null && value !== '') { css += ' ' + key + ': ' + value + separator; diff --git a/packages/svelte/src/internal/shared/clone.js b/packages/svelte/src/internal/shared/clone.js index b8f99ee198..1a83adf8c5 100644 --- a/packages/svelte/src/internal/shared/clone.js +++ b/packages/svelte/src/internal/shared/clone.js @@ -89,7 +89,7 @@ function clone(value, cloned, path, paths, original = null, no_tojson = false) { cloned.set(original, copy); } - for (var key in value) { + for (var key of Object.keys(value)) { copy[key] = clone( // @ts-expect-error value[key], From 73098bb26c6f06e7fd1b0746d817d2c5ee90755f Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 18 Feb 2026 10:54:11 -0700 Subject: [PATCH 06/54] Merge commit from fork * fix: check to make sure `svelte:element` tags are valid during SSR * fix: error instead of warn * better sharing * nit --- .changeset/all-pandas-appear.md | 5 +++++ .../docs/98-reference/.generated/server-errors.md | 8 ++++++++ packages/svelte/messages/server-errors/errors.md | 6 ++++++ .../src/compiler/phases/1-parse/state/element.js | 15 +++++++++++---- packages/svelte/src/internal/server/errors.js | 13 +++++++++++++ packages/svelte/src/internal/server/index.js | 12 ++++++++++-- packages/svelte/src/utils.js | 13 +++++++++++++ .../dynamic-element-xss-prevention/_config.js | 8 ++++++++ .../dynamic-element-xss-prevention/main.svelte | 5 +++++ 9 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 .changeset/all-pandas-appear.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/main.svelte diff --git a/.changeset/all-pandas-appear.md b/.changeset/all-pandas-appear.md new file mode 100644 index 0000000000..0025cc1836 --- /dev/null +++ b/.changeset/all-pandas-appear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: check to make sure `svelte:element` tags are valid during SSR diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index c98756afec..0ee6fd0614 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -16,6 +16,14 @@ Encountered asynchronous work while rendering synchronously. You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +### dynamic_element_invalid_tag + +``` +`` is not a valid element name — the element will not be rendered +``` + +The value passed to the `this` prop of `` must be a valid HTML element, SVG element, MathML element, or custom element name. A value containing invalid characters (such as whitespace or special characters) was provided, which could be a security risk. Ensure only valid tag names are passed. + ### html_deprecated ``` diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index fd4c17e2a7..533e5405ed 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -10,6 +10,12 @@ Some platforms require configuration flags to enable this API. Consult your plat You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +## dynamic_element_invalid_tag + +> `` is not a valid element name — the element will not be rendered + +The value passed to the `this` prop of `` must be a valid HTML element, SVG element, MathML element, or custom element name. A value containing invalid characters (such as whitespace or special characters) was provided, which could be a security risk. Ensure only valid tag names are passed. + ## html_deprecated > The `html` property of server render results has been deprecated. Use `body` instead. diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index d9fe33bbac..91d072c1f3 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -2,7 +2,7 @@ /** @import { Location } from 'locate-character' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from '../index.js' */ -import { is_void } from '../../../../utils.js'; +import { is_void, REGEX_VALID_TAG_NAME } from '../../../../utils.js'; import read_expression from '../read/expression.js'; import { read_script } from '../read/script.js'; import read_style from '../read/style.js'; @@ -24,8 +24,15 @@ const regex_whitespace_or_slash_or_closing_tag = /(\s|\/|>)/; const regex_token_ending_character = /[\s=/>"']/; const regex_starts_with_quote_characters = /^["']/; const regex_attribute_value = /^(?:"([^"]*)"|'([^'])*'|([^>\s]+))/; -const regex_valid_element_name = - /^(?:![a-zA-Z]+|[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9])$/; +/** @param {string} name */ +function is_valid_element_name(name) { + // DOCTYPE (e.g. !DOCTYPE) + if (/^![a-zA-Z]+$/.test(name)) return true; + // svelte:* meta tags (e.g. svelte:element, svelte:head) + if (/^[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$/.test(name)) return true; + // standard HTML/SVG/MathML elements and custom elements + return REGEX_VALID_TAG_NAME.test(name); +} export const regex_valid_component_name = // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers adjusted for our needs // (must start with uppercase letter if no dots, can contain dots) @@ -134,7 +141,7 @@ export default function element(parser) { e.svelte_meta_invalid_tag(bounds, list(Array.from(meta_tags.keys()))); } - if (!regex_valid_element_name.test(tag.name) && !regex_valid_component_name.test(tag.name)) { + if (!is_valid_element_name(tag.name) && !regex_valid_component_name.test(tag.name)) { // in the middle of typing -> allow in loose mode if (!parser.loose || !tag.name.endsWith('.')) { const bounds = { start: start + 1, end: start + 1 + tag.name.length }; diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index c966c32062..15f8b2174d 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -14,6 +14,19 @@ export function async_local_storage_unavailable() { throw error; } +/** + * `` is not a valid element name — the element will not be rendered + * @param {string} tag + * @returns {never} + */ +export function dynamic_element_invalid_tag(tag) { + const error = new Error(`dynamic_element_invalid_tag\n\`\` is not a valid element name — the element will not be rendered\nhttps://svelte.dev/e/dynamic_element_invalid_tag`); + + error.name = 'Svelte error'; + + throw error; +} + /** * Encountered asynchronous work while rendering synchronously. * @returns {never} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index d978ed6355..06023be494 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -13,9 +13,14 @@ import { } from '../../constants.js'; import { escape_html } from '../../escaping.js'; import { DEV } from 'esm-env'; -import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js'; +import { EMPTY_COMMENT, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js'; import { validate_store } from '../shared/validate.js'; -import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js'; +import { + is_boolean_attribute, + is_raw_text_element, + is_void, + REGEX_VALID_TAG_NAME +} from '../../utils.js'; import { Renderer } from './renderer.js'; import * as e from './errors.js'; @@ -35,6 +40,9 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop) renderer.push(''); if (tag) { + if (!REGEX_VALID_TAG_NAME.test(tag)) { + e.dynamic_element_invalid_tag(tag); + } renderer.push(`<${tag}`); attributes_fn(); renderer.push(`>`); diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index d63d4ff801..57561e6dc7 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -480,6 +480,19 @@ export function is_raw_text_element(name) { return RAW_TEXT_ELEMENTS.includes(/** @type {typeof RAW_TEXT_ELEMENTS[number]} */ (name)); } +// Matches valid HTML/SVG/MathML element names and custom element names. +// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name +// +// Standard elements: ASCII alpha start, followed by ASCII alphanumerics. +// Custom elements: ASCII alpha start, followed by any mix of PCENChar (which +// includes ASCII alphanumerics, `-`, `.`, `_`, and specified Unicode ranges), +// with at least one hyphen required somewhere after the first character. +// +// Rejects strings containing whitespace, quotes, angle brackets, slashes, equals, +// or other characters that could break out of a tag-name token and enable markup injection. +export const REGEX_VALID_TAG_NAME = + /^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9.\-_\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]+)*$/u; + /** * Prevent devtools trying to make `location` a clickable link by inserting a zero-width space * @template {string | undefined} T diff --git a/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/_config.js b/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/_config.js new file mode 100644 index 0000000000..ddbe9a4b16 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + props: { + tag: 'svg onload=alert(1)' + }, + error: 'dynamic_element_invalid_tag' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/main.svelte b/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/main.svelte new file mode 100644 index 0000000000..94ad88cf0a --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/dynamic-element-xss-prevention/main.svelte @@ -0,0 +1,5 @@ + + +ok From a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 18 Feb 2026 10:54:28 -0700 Subject: [PATCH 07/54] Merge commit from fork * fix: strip event handlers during SSR * whatever conduitry you're not that cool * tweak --- .changeset/chilly-comics-wear.md | 5 +++++ packages/svelte/src/internal/server/index.js | 3 +++ .../_expected.html | 3 +++ .../spread-attributes-event-handler-xss/main.svelte | 13 +++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 .changeset/chilly-comics-wear.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte diff --git a/.changeset/chilly-comics-wear.md b/.changeset/chilly-comics-wear.md new file mode 100644 index 0000000000..c8be28b532 --- /dev/null +++ b/.changeset/chilly-comics-wear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: strip event handlers during SSR diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 06023be494..0864c7704d 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -158,6 +158,9 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) { name = name.toLowerCase(); } + // omit event handler attributes + if (name.length > 2 && name.startsWith('on')) continue; + if (is_input) { if (name === 'defaultvalue' || name === 'defaultchecked') { name = name === 'defaultvalue' ? 'value' : 'checked'; diff --git a/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/_expected.html b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/_expected.html new file mode 100644 index 0000000000..2a982292e5 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/_expected.html @@ -0,0 +1,3 @@ +
content
+ +photo diff --git a/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte new file mode 100644 index 0000000000..2a8c17f048 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/spread-attributes-event-handler-xss/main.svelte @@ -0,0 +1,13 @@ + + +
content
+photo From f7c80da18c215e3727c2a611b0b8744cc6e504c5 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Wed, 18 Feb 2026 18:54:52 +0100 Subject: [PATCH 08/54] Merge commit from fork --- .changeset/fix-that-thing.md | 5 +++++ packages/svelte/src/internal/server/renderer.js | 3 ++- .../samples/option-body-escaped/_expected.html | 1 + .../samples/option-body-escaped/main.svelte | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-that-thing.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte diff --git a/.changeset/fix-that-thing.md b/.changeset/fix-that-thing.md new file mode 100644 index 0000000000..aa32c4dce8 --- /dev/null +++ b/.changeset/fix-that-thing.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: always escape option body in SSR \ No newline at end of file diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 9df914b35a..7f9a922f33 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -12,6 +12,7 @@ import { get_render_context, with_render_context, init_render_context } from './ import { sha256 } from './crypto.js'; import * as devalue from 'devalue'; import { noop } from '../shared/utils.js'; +import { escape_html } from '../../escaping.js'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ @@ -275,7 +276,7 @@ export class Renderer { renderer.#out.push(' selected=""'); } - renderer.#out.push(`>${body}${is_rich ? '' : ''}`); + renderer.#out.push(`>${escape_html(body)}${is_rich ? '' : ''}`); // super edge case, but may as well handle it if (head) { diff --git a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html new file mode 100644 index 0000000000..f1f609d095 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte new file mode 100644 index 0000000000..ab28f8bda5 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte @@ -0,0 +1,6 @@ + + From 781052eeabb6f564ec73fef4610185f2efe8ffa3 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 18 Feb 2026 10:57:56 -0700 Subject: [PATCH 09/54] chore: upgrade `devalue` (#17739) ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [ ] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [ ] 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 - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/moody-flies-ring.md | 5 +++++ packages/svelte/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/moody-flies-ring.md diff --git a/.changeset/moody-flies-ring.md b/.changeset/moody-flies-ring.md new file mode 100644 index 0000000000..fd79770454 --- /dev/null +++ b/.changeset/moody-flies-ring.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: upgrade `devalue` diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 05493c6591..0fa287deaa 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -175,7 +175,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.2", + "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d3427fae0..5b6502dec5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^5.6.3 + version: 5.6.3 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1246,8 +1246,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -3518,7 +3518,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.6.2: {} + devalue@5.6.3: {} dir-glob@3.0.1: dependencies: From f855a0b770f0e327fe1c9b2f42c006c2dad45dee Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 18 Feb 2026 12:02:46 -0700 Subject: [PATCH 10/54] fix: misc option escaping and backwards compatibility (#17741) ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [ ] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [ ] 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 - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/big-planets-appear.md | 5 +++++ packages/svelte/src/internal/server/errors.js | 14 +++++++------- packages/svelte/src/internal/server/renderer.js | 8 ++++---- packages/svelte/src/internal/shared/attributes.js | 4 +++- packages/svelte/src/internal/shared/utils.js | 1 + .../samples/option-body-escaped/_expected.html | 2 +- .../samples/option-body-escaped/main.svelte | 5 ++++- 7 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 .changeset/big-planets-appear.md diff --git a/.changeset/big-planets-appear.md b/.changeset/big-planets-appear.md new file mode 100644 index 0000000000..b5bd640f5f --- /dev/null +++ b/.changeset/big-planets-appear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: misc option escaping and backwards compatibility diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index 15f8b2174d..d1e594acfd 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -15,12 +15,11 @@ export function async_local_storage_unavailable() { } /** - * `` is not a valid element name — the element will not be rendered - * @param {string} tag + * Encountered asynchronous work while rendering synchronously. * @returns {never} */ -export function dynamic_element_invalid_tag(tag) { - const error = new Error(`dynamic_element_invalid_tag\n\`\` is not a valid element name — the element will not be rendered\nhttps://svelte.dev/e/dynamic_element_invalid_tag`); +export function await_invalid() { + const error = new Error(`await_invalid\nEncountered asynchronous work while rendering synchronously.\nhttps://svelte.dev/e/await_invalid`); error.name = 'Svelte error'; @@ -28,11 +27,12 @@ export function dynamic_element_invalid_tag(tag) { } /** - * Encountered asynchronous work while rendering synchronously. + * `` is not a valid element name — the element will not be rendered + * @param {string} tag * @returns {never} */ -export function await_invalid() { - const error = new Error(`await_invalid\nEncountered asynchronous work while rendering synchronously.\nhttps://svelte.dev/e/await_invalid`); +export function dynamic_element_invalid_tag(tag) { + const error = new Error(`dynamic_element_invalid_tag\n\`\` is not a valid element name — the element will not be rendered\nhttps://svelte.dev/e/dynamic_element_invalid_tag`); error.name = 'Svelte error'; diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 7f9a922f33..610e9bb268 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -11,7 +11,7 @@ import { attributes } from './index.js'; import { get_render_context, with_render_context, init_render_context } from './render-context.js'; import { sha256 } from './crypto.js'; import * as devalue from 'devalue'; -import { noop } from '../shared/utils.js'; +import { has_own_property, noop } from '../shared/utils.js'; import { escape_html } from '../../escaping.js'; /** @typedef {'head' | 'body'} RendererType */ @@ -268,7 +268,7 @@ export class Renderer { * @param {{ head?: string, body: any }} content */ const close = (renderer, value, { head, body }) => { - if (Object.hasOwn(attrs, 'value')) { + if (has_own_property.call(attrs, 'value')) { value = attrs.value; } @@ -276,7 +276,7 @@ export class Renderer { renderer.#out.push(' selected=""'); } - renderer.#out.push(`>${escape_html(body)}${is_rich ? '' : ''}`); + renderer.#out.push(`>${body}${is_rich ? '' : ''}`); // super edge case, but may as well handle it if (head) { @@ -299,7 +299,7 @@ export class Renderer { } }); } else { - close(this, body, { body }); + close(this, body, { body: escape_html(body) }); } } diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js index 21bfd3d5a8..487a40baf3 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.js @@ -1,5 +1,6 @@ import { escape_html } from '../../escaping.js'; import { clsx as _clsx } from 'clsx'; +import { has_own_property } from './utils.js'; /** * `
` should be rendered as `
` and _not_ @@ -27,7 +28,8 @@ export function attr(name, value, is_boolean = false) { is_boolean = true; } if (value == null || (!value && is_boolean)) return ''; - const normalized = (Object.hasOwn(replacements, name) && replacements[name].get(value)) || value; + const normalized = + (has_own_property.call(replacements, name) && replacements[name].get(value)) || value; const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`; return ` ${name}${assignment}`; } diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 771f6b345c..c9cc2c2d78 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -12,6 +12,7 @@ export var object_prototype = Object.prototype; export var array_prototype = Array.prototype; export var get_prototype_of = Object.getPrototypeOf; export var is_extensible = Object.isExtensible; +export var has_own_property = Object.prototype.hasOwnProperty; /** * @param {any} thing diff --git a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html index f1f609d095..0802f01083 100644 --- a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html +++ b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/_expected.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte index ab28f8bda5..a2f96383d1 100644 --- a/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte +++ b/packages/svelte/tests/server-side-rendering/samples/option-body-escaped/main.svelte @@ -1,6 +1,9 @@ From 8ea33bf7fe86d0d53cbe4104419cda9b4bb0442f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:08:09 -0700 Subject: [PATCH 11/54] Version Packages (#17740) 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.5 ### Patch Changes - fix: check to make sure `svelte:element` tags are valid during SSR ([`73098bb26c6f06e7fd1b0746d817d2c5ee90755f`](https://github.com/sveltejs/svelte/commit/73098bb26c6f06e7fd1b0746d817d2c5ee90755f)) - fix: misc option escaping and backwards compatibility ([#17741](https://github.com/sveltejs/svelte/pull/17741)) - fix: strip event handlers during SSR ([`a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5`](https://github.com/sveltejs/svelte/commit/a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5)) - fix: replace usage of `for in` with `for of Object.keys` ([`f89c7ddd7eebaa1ef3cc540400bec2c9140b330c`](https://github.com/sveltejs/svelte/commit/f89c7ddd7eebaa1ef3cc540400bec2c9140b330c)) - fix: always escape option body in SSR ([`f7c80da18c215e3727c2a611b0b8744cc6e504c5`](https://github.com/sveltejs/svelte/commit/f7c80da18c215e3727c2a611b0b8744cc6e504c5)) - chore: upgrade `devalue` ([#17739](https://github.com/sveltejs/svelte/pull/17739)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/all-pandas-appear.md | 5 ----- .changeset/big-planets-appear.md | 5 ----- .changeset/chilly-comics-wear.md | 5 ----- .changeset/elliott-elliott-elliott.md | 5 ----- .changeset/fix-that-thing.md | 5 ----- .changeset/moody-flies-ring.md | 5 ----- packages/svelte/CHANGELOG.md | 16 ++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 9 files changed, 18 insertions(+), 32 deletions(-) delete mode 100644 .changeset/all-pandas-appear.md delete mode 100644 .changeset/big-planets-appear.md delete mode 100644 .changeset/chilly-comics-wear.md delete mode 100644 .changeset/elliott-elliott-elliott.md delete mode 100644 .changeset/fix-that-thing.md delete mode 100644 .changeset/moody-flies-ring.md diff --git a/.changeset/all-pandas-appear.md b/.changeset/all-pandas-appear.md deleted file mode 100644 index 0025cc1836..0000000000 --- a/.changeset/all-pandas-appear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: check to make sure `svelte:element` tags are valid during SSR diff --git a/.changeset/big-planets-appear.md b/.changeset/big-planets-appear.md deleted file mode 100644 index b5bd640f5f..0000000000 --- a/.changeset/big-planets-appear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: misc option escaping and backwards compatibility diff --git a/.changeset/chilly-comics-wear.md b/.changeset/chilly-comics-wear.md deleted file mode 100644 index c8be28b532..0000000000 --- a/.changeset/chilly-comics-wear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: strip event handlers during SSR diff --git a/.changeset/elliott-elliott-elliott.md b/.changeset/elliott-elliott-elliott.md deleted file mode 100644 index cf2e2e1cc1..0000000000 --- a/.changeset/elliott-elliott-elliott.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: replace usage of `for in` with `for of Object.keys` diff --git a/.changeset/fix-that-thing.md b/.changeset/fix-that-thing.md deleted file mode 100644 index aa32c4dce8..0000000000 --- a/.changeset/fix-that-thing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: always escape option body in SSR \ No newline at end of file diff --git a/.changeset/moody-flies-ring.md b/.changeset/moody-flies-ring.md deleted file mode 100644 index fd79770454..0000000000 --- a/.changeset/moody-flies-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: upgrade `devalue` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 9b2a9c1ac7..fa0cc5a775 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,21 @@ # svelte +## 5.51.5 + +### Patch Changes + +- fix: check to make sure `svelte:element` tags are valid during SSR ([`73098bb26c6f06e7fd1b0746d817d2c5ee90755f`](https://github.com/sveltejs/svelte/commit/73098bb26c6f06e7fd1b0746d817d2c5ee90755f)) + +- fix: misc option escaping and backwards compatibility ([#17741](https://github.com/sveltejs/svelte/pull/17741)) + +- fix: strip event handlers during SSR ([`a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5`](https://github.com/sveltejs/svelte/commit/a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5)) + +- fix: replace usage of `for in` with `for of Object.keys` ([`f89c7ddd7eebaa1ef3cc540400bec2c9140b330c`](https://github.com/sveltejs/svelte/commit/f89c7ddd7eebaa1ef3cc540400bec2c9140b330c)) + +- fix: always escape option body in SSR ([`f7c80da18c215e3727c2a611b0b8744cc6e504c5`](https://github.com/sveltejs/svelte/commit/f7c80da18c215e3727c2a611b0b8744cc6e504c5)) + +- chore: upgrade `devalue` ([#17739](https://github.com/sveltejs/svelte/pull/17739)) + ## 5.51.4 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 0fa287deaa..bfbc537a43 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.4", + "version": "5.51.5", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index c2d6ca81fb..af76149596 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.4'; +export const VERSION = '5.51.5'; export const PUBLIC_VERSION = '5'; From 9f48e7620f5bf017a04b2a57af82b839a4e8f496 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:34:38 +0100 Subject: [PATCH 12/54] fix: repair dynamic component truthy/falsy hydration mismatches (#17737) Fixes #17735 Use the if/else hydration markers to know what "branch" (component or no component) was rendered, and repair if differing. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/silly-mammals-fold.md | 5 +++ .../server/visitors/shared/component.js | 44 +++++++++++++------ .../client/dom/blocks/svelte-component.js | 37 +++++++++++++++- packages/svelte/src/internal/server/index.js | 10 ++--- .../Component.svelte | 7 +++ .../dynamic-component-css-props/_config.js | 10 +++++ .../dynamic-component-css-props/main.svelte | 6 +++ .../HelloWorld.svelte | 1 + .../_config.js | 9 ++++ .../main.svelte | 13 ++++++ 10 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 .changeset/silly-mammals-fold.md create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/HelloWorld.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/main.svelte diff --git a/.changeset/silly-mammals-fold.md b/.changeset/silly-mammals-fold.md new file mode 100644 index 0000000000..3513b4339e --- /dev/null +++ b/.changeset/silly-mammals-fold.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: repair dynamic component truthy/falsy hydration mismatches diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index 6a2c6eb0be..b0e086b7ad 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -1,7 +1,14 @@ /** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../../types.js' */ -import { empty_comment, build_attribute_value, PromiseOptimiser } from './utils.js'; +import { + empty_comment, + build_attribute_value, + PromiseOptimiser, + block_open_else, + block_open, + block_close +} from './utils.js'; import * as b from '#compiler/builders'; import { is_element_node } from '../../../../nodes.js'; import { dev } from '../../../../../state.js'; @@ -300,9 +307,22 @@ export function build_inline_component(node, expression, context) { node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic); /** @type {Statement} */ - let statement = b.stmt( - (dynamic ? b.maybe_call : b.call)(expression, b.id('$$renderer'), props_expression) - ); + let statement = b.stmt(b.call(expression, b.id('$$renderer'), props_expression)); + + if (dynamic) { + statement = b.if( + expression, + b.block([ + b.stmt(b.call('$$renderer.push', block_open)), + statement, + b.stmt(b.call('$$renderer.push', block_close)) + ]), + b.block([ + b.stmt(b.call('$$renderer.push', block_open_else)), + b.stmt(b.call('$$renderer.push', block_close)) + ]) + ); + } if (snippet_declarations.length > 0) { statement = b.block([...snippet_declarations, statement]); @@ -326,16 +346,14 @@ export function build_inline_component(node, expression, context) { optimiser.check_blockers(node.metadata.expression); } - context.state.template.push( - ...optimiser.render_block([ - dynamic && custom_css_props.length === 0 - ? b.stmt(b.call('$$renderer.push', empty_comment)) - : b.empty, - statement - ]) - ); + context.state.template.push(...optimiser.render_block([statement])); - if (!optimiser.is_async() && !context.state.is_standalone && custom_css_props.length === 0) { + if ( + !dynamic && + !optimiser.is_async() && + !context.state.is_standalone && + custom_css_props.length === 0 + ) { context.state.template.push(empty_comment); } } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index 134e57e627..0e9434386c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,8 +1,17 @@ /** @import { TemplateNode, Dom } from '#client' */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { block } from '../../reactivity/effects.js'; -import { hydrate_next, hydrating } from '../hydration.js'; +import { + hydrate_next, + hydrate_node, + hydrating, + read_hydration_instruction, + set_hydrate_node, + set_hydrating, + skip_nodes +} from '../hydration.js'; import { BranchManager } from './branches.js'; +import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js'; /** * @template P @@ -13,7 +22,11 @@ import { BranchManager } from './branches.js'; * @returns {void} */ export function component(node, get_component, render_fn) { + /** @type {TemplateNode | undefined} */ + var hydration_start_node; + if (hydrating) { + hydration_start_node = hydrate_node; hydrate_next(); } @@ -21,6 +34,28 @@ export function component(node, get_component, render_fn) { block(() => { var component = get_component() ?? null; + + if (hydrating) { + var data = read_hydration_instruction(/** @type {TemplateNode} */ (hydration_start_node)); + + var server_had_component = data === HYDRATION_START; + var client_has_component = component !== null; + + if (server_had_component !== client_has_component) { + // Hydration mismatch: skip the server-rendered nodes and render fresh + var anchor = skip_nodes(); + + set_hydrate_node(anchor); + branches.anchor = anchor; + + set_hydrating(false); + branches.ensure(component, component && ((target) => render_fn(target, component))); + set_hydrating(true); + + return; + } + } + branches.ensure(component, component && ((target) => render_fn(target, component))); }, EFFECT_TRANSPARENT); } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 0864c7704d..6ab32a0d6e 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -105,16 +105,16 @@ export function css_props(renderer, is_html, props, component, dynamic = false) renderer.push(``); } - if (dynamic) { + component(); + + if (!dynamic) { renderer.push(''); } - component(); - if (is_html) { - renderer.push(``); + renderer.push(''); } else { - renderer.push(``); + renderer.push(''); } } diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/Component.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/Component.svelte new file mode 100644 index 0000000000..57add17179 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/Component.svelte @@ -0,0 +1,7 @@ +
Hello
+ + diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/_config.js new file mode 100644 index 0000000000..3078392055 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/_config.js @@ -0,0 +1,10 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + assert.htmlEqual( + target.innerHTML, + `
Hello
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/main.svelte new file mode 100644 index 0000000000..80aec26eef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-css-props/main.svelte @@ -0,0 +1,6 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/HelloWorld.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/HelloWorld.svelte new file mode 100644 index 0000000000..52ea02c559 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/HelloWorld.svelte @@ -0,0 +1 @@ +
Hello world
diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/_config.js new file mode 100644 index 0000000000..90dae38ad8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/_config.js @@ -0,0 +1,9 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, `

Test

Hello world
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/main.svelte new file mode 100644 index 0000000000..8a410da609 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-falsy-hydrate/main.svelte @@ -0,0 +1,13 @@ + + +

Test

+ + From be24b0dca70e3959b38c1264077f5ac3a31f1e6c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Feb 2026 15:54:46 -0500 Subject: [PATCH 13/54] feat: support TrustedHTML in {@html} expressions (#17701) Follow-up to #16271. ## Summary - Allow `{@html}` blocks to accept `TrustedHTML` objects (from TrustedTypes policies) without coercing them to strings - This enables usage like `{@html myPolicy.createHTML(someHTML)}` - Works in regular HTML, SVG, and MathML contexts ## Changes - **`html.js`**: Instead of calling `create_fragment_from_html`, create the wrapper element directly (`