From 06e6ef83b801a3ca014af4629abad76ac2d35eef Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 27 Jan 2026 22:44:26 -0500 Subject: [PATCH 01/71] failing test (#12791) --- .../tests/hydration/samples/safari-borking-2/Child.svelte | 6 ++++++ .../tests/hydration/samples/safari-borking-2/_config.js | 5 +++++ .../tests/hydration/samples/safari-borking-2/_override.html | 1 + .../tests/hydration/samples/safari-borking-2/main.svelte | 6 ++++++ 4 files changed, 18 insertions(+) create mode 100644 packages/svelte/tests/hydration/samples/safari-borking-2/Child.svelte create mode 100644 packages/svelte/tests/hydration/samples/safari-borking-2/_config.js create mode 100644 packages/svelte/tests/hydration/samples/safari-borking-2/_override.html create mode 100644 packages/svelte/tests/hydration/samples/safari-borking-2/main.svelte diff --git a/packages/svelte/tests/hydration/samples/safari-borking-2/Child.svelte b/packages/svelte/tests/hydration/samples/safari-borking-2/Child.svelte new file mode 100644 index 0000000000..a62bd4ec90 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/safari-borking-2/Child.svelte @@ -0,0 +1,6 @@ + + +
+{message} diff --git a/packages/svelte/tests/hydration/samples/safari-borking-2/_config.js b/packages/svelte/tests/hydration/samples/safari-borking-2/_config.js new file mode 100644 index 0000000000..cf22ff2c85 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/safari-borking-2/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + expect_hydration_error: true +}); diff --git a/packages/svelte/tests/hydration/samples/safari-borking-2/_override.html b/packages/svelte/tests/hydration/samples/safari-borking-2/_override.html new file mode 100644 index 0000000000..17ec01970a --- /dev/null +++ b/packages/svelte/tests/hydration/samples/safari-borking-2/_override.html @@ -0,0 +1 @@ +
call +636-555-3226 now

42

diff --git a/packages/svelte/tests/hydration/samples/safari-borking-2/main.svelte b/packages/svelte/tests/hydration/samples/safari-borking-2/main.svelte new file mode 100644 index 0000000000..daa25067ec --- /dev/null +++ b/packages/svelte/tests/hydration/samples/safari-borking-2/main.svelte @@ -0,0 +1,6 @@ + + + +

{40 + 2}

From 16fec7207a39d6f913e1ec32fbe5b2a6f2db838f Mon Sep 17 00:00:00 2001 From: PandaMan Date: Thu, 29 Jan 2026 00:17:38 +0800 Subject: [PATCH 02/71] fix: make onintrostart respect delay parameter (#17567) * fix: make onintrostart respect delay parameter Fixes #14009 The onintrostart event now fires after the delay period completes, rather than immediately when the transition is initiated. This ensures that the event accurately reflects when the intro animation actually starts. Changes: - Added on_start callback parameter to animate() function - Dispatch introstart event after delay animation finishes - Handle edge case where duration is 0 but delay > 0 * fix: format code with prettier * add (failing) tests * fix * changeset * missed a spot * fix --------- Co-authored-by: Miner Co-authored-by: Rich Harris --- .changeset/sweet-rings-watch.md | 5 +++ .../client/dom/elements/transitions.js | 9 +++--- .../transition-delayed-events-css/_config.js | 32 +++++++++++++++++++ .../transition-delayed-events-css/main.svelte | 23 +++++++++++++ .../transition-delayed-events-js/_config.js | 32 +++++++++++++++++++ .../transition-delayed-events-js/main.svelte | 23 +++++++++++++ 6 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 .changeset/sweet-rings-watch.md create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/main.svelte diff --git a/.changeset/sweet-rings-watch.md b/.changeset/sweet-rings-watch.md new file mode 100644 index 0000000000..8ce11b2672 --- /dev/null +++ b/.changeset/sweet-rings-watch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: fire introstart/outrostart events after delay, if specified diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index e9f0953df7..170b40780a 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -239,8 +239,6 @@ export function transition(flags, element, get_fn, get_params) { intro?.abort(); } - dispatch_event(element, 'introstart'); - intro = animate(element, get_options(), outro, 1, () => { dispatch_event(element, 'introend'); @@ -260,8 +258,6 @@ export function transition(flags, element, get_fn, get_params) { element.inert = true; - dispatch_event(element, 'outrostart'); - outro = animate(element, get_options(), intro, 0, () => { dispatch_event(element, 'outroend'); fn?.(); @@ -345,7 +341,8 @@ function animate(element, options, counterpart, t2, on_finish) { counterpart?.deactivate(); - if (!options?.duration) { + if (!options?.duration && !options?.delay) { + dispatch_event(element, is_intro ? 'introstart' : 'outrostart'); on_finish(); return { @@ -385,6 +382,8 @@ function animate(element, options, counterpart, t2, on_finish) { // remove dummy animation from the stack to prevent conflict with main animation animation.cancel(); + dispatch_event(element, is_intro ? 'introstart' : 'outrostart'); + // for bidirectional transitions, we start from the current position, // rather than doing a full intro/outro var t1 = counterpart?.t() ?? 1 - t2; diff --git a/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/_config.js new file mode 100644 index 0000000000..36daefa3b7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from '../../../../src/index-client.js'; +import { test } from '../../test'; + +export default test({ + test({ assert, raf, target, logs }) { + const [btn] = target.querySelectorAll('button'); + + // in + flushSync(() => btn.click()); + assert.deepEqual(logs, []); + raf.tick(1); + assert.deepEqual(logs, []); + + raf.tick(100); + assert.deepEqual(logs, ['introstart']); + + raf.tick(200); + assert.deepEqual(logs, ['introstart', 'introend']); + + // out + flushSync(() => btn.click()); + assert.deepEqual(logs, ['introstart', 'introend']); + raf.tick(201); + assert.deepEqual(logs, ['introstart', 'introend']); + + raf.tick(300); + assert.deepEqual(logs, ['introstart', 'introend', 'outrostart']); + + raf.tick(400); + assert.deepEqual(logs, ['introstart', 'introend', 'outrostart', 'outroend']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/main.svelte new file mode 100644 index 0000000000..cd91790c17 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-css/main.svelte @@ -0,0 +1,23 @@ + + + + +{#if visible} +

console.log('introstart')} + onintroend={() => console.log('introend')} + onoutrostart={() => console.log('outrostart')} + onoutroend={() => console.log('outroend')} + >delayed fade

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/_config.js new file mode 100644 index 0000000000..36daefa3b7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from '../../../../src/index-client.js'; +import { test } from '../../test'; + +export default test({ + test({ assert, raf, target, logs }) { + const [btn] = target.querySelectorAll('button'); + + // in + flushSync(() => btn.click()); + assert.deepEqual(logs, []); + raf.tick(1); + assert.deepEqual(logs, []); + + raf.tick(100); + assert.deepEqual(logs, ['introstart']); + + raf.tick(200); + assert.deepEqual(logs, ['introstart', 'introend']); + + // out + flushSync(() => btn.click()); + assert.deepEqual(logs, ['introstart', 'introend']); + raf.tick(201); + assert.deepEqual(logs, ['introstart', 'introend']); + + raf.tick(300); + assert.deepEqual(logs, ['introstart', 'introend', 'outrostart']); + + raf.tick(400); + assert.deepEqual(logs, ['introstart', 'introend', 'outrostart', 'outroend']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/main.svelte b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/main.svelte new file mode 100644 index 0000000000..c7167aa8c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/transition-delayed-events-js/main.svelte @@ -0,0 +1,23 @@ + + + + +{#if visible} +

console.log('introstart')} + onintroend={() => console.log('introend')} + onoutrostart={() => console.log('outrostart')} + onoutroend={() => console.log('outroend')} + >delayed fade

+{/if} From 08448c501b97a4617171ee9db69c60aa3bd0bbcc Mon Sep 17 00:00:00 2001 From: GiantDragon9090 <102870250+GiantDragon9090@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:55:32 -0800 Subject: [PATCH 03/71] docs: update FAQ to mention SvelteKit hash-based routing support (#17546) Fixes #14919 Co-authored-by: Gittensor Miner --- documentation/docs/07-misc/99-faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/07-misc/99-faq.md b/documentation/docs/07-misc/99-faq.md index 4c1d19a262..035629e5ae 100644 --- a/documentation/docs/07-misc/99-faq.md +++ b/documentation/docs/07-misc/99-faq.md @@ -91,7 +91,7 @@ Some resources for getting started with testing: ## Is there a router? -The official routing library is [SvelteKit](/docs/kit). SvelteKit provides a filesystem router, server-side rendering (SSR), and hot module reloading (HMR) in one easy-to-use package. It shares similarities with Next.js for React and Nuxt.js for Vue. +The official routing library is [SvelteKit](/docs/kit). SvelteKit provides a filesystem router, server-side rendering (SSR), and hot module reloading (HMR) in one easy-to-use package. It shares similarities with Next.js for React and Nuxt.js for Vue. SvelteKit also supports hash-based routing for client-side applications. However, you can use any router library. A sampling of available routers are highlighted [on the packages page](/packages#routing). From 3256a75b547d9ee440be0c2b51865b31cb6d9c88 Mon Sep 17 00:00:00 2001 From: Razin Shafayet Date: Wed, 28 Jan 2026 23:56:17 +0600 Subject: [PATCH 04/71] fix: ignore popover elements in a11y_consider_explicit_label check (#17367) * fix: ignore popover elements in a11y_consider_explicit_label check * chore: add changeset * Apply suggestions from code review * Apply suggestions from code review * fix --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/shaggy-phones-laugh.md | 5 +++++ .../phases/2-analyze/visitors/shared/a11y/index.js | 4 ++++ .../samples/a11y-popover-label/input.svelte | 3 +++ .../samples/a11y-popover-label/warnings.json | 14 ++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 .changeset/shaggy-phones-laugh.md create mode 100644 packages/svelte/tests/validator/samples/a11y-popover-label/input.svelte create mode 100644 packages/svelte/tests/validator/samples/a11y-popover-label/warnings.json diff --git a/.changeset/shaggy-phones-laugh.md b/.changeset/shaggy-phones-laugh.md new file mode 100644 index 0000000000..8cb711491b --- /dev/null +++ b/.changeset/shaggy-phones-laugh.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: disregard `popover` elements when determining whether an element has content diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js index 50b107d068..f5f4982ed2 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js @@ -824,6 +824,10 @@ function has_content(element) { } if (node.type === 'RegularElement' || node.type === 'SvelteElement') { + if (node.attributes.some((a) => a.type === 'Attribute' && a.name === 'popover')) { + continue; + } + if ( node.name === 'img' && node.attributes.some((node) => node.type === 'Attribute' && node.name === 'alt') diff --git a/packages/svelte/tests/validator/samples/a11y-popover-label/input.svelte b/packages/svelte/tests/validator/samples/a11y-popover-label/input.svelte new file mode 100644 index 0000000000..6fafa0a10c --- /dev/null +++ b/packages/svelte/tests/validator/samples/a11y-popover-label/input.svelte @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/validator/samples/a11y-popover-label/warnings.json b/packages/svelte/tests/validator/samples/a11y-popover-label/warnings.json new file mode 100644 index 0000000000..f5fd09c89d --- /dev/null +++ b/packages/svelte/tests/validator/samples/a11y-popover-label/warnings.json @@ -0,0 +1,14 @@ +[ + { + "code": "a11y_consider_explicit_label", + "message": "Buttons and links should either contain text or have an `aria-label`, `aria-labelledby` or `title` attribute", + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 3, + "column": 9 + } + } +] From 2b4253f33271dbe8024cfb3946964e4cd6df0315 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 13:40:10 -0500 Subject: [PATCH 05/71] chore: allow sandbox to 'download' from tests (#17576) --- playgrounds/sandbox/scripts/download.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/playgrounds/sandbox/scripts/download.js b/playgrounds/sandbox/scripts/download.js index b0bad54e73..e3c862cb61 100644 --- a/playgrounds/sandbox/scripts/download.js +++ b/playgrounds/sandbox/scripts/download.js @@ -39,10 +39,14 @@ function is_local_directory(arg) { // Check if it's a local directory first (before URL parsing) const is_local = is_local_directory(url_arg); +const resolved_test_path = ['runtime-runes', 'runtime-legacy'] + .map((d) => path.resolve(`${base_dir}/../../../packages/svelte/tests/${d}/samples/${url_arg}`)) + .find(fs.existsSync); + /** @type {URL | null} */ let url = null; -if (!is_local) { +if (!is_local && !resolved_test_path) { try { url = new URL(url_arg); } catch (e) { @@ -599,6 +603,17 @@ let files; if (is_local) { console.log(`Processing local directory: ${url_arg}`); files = process_directory(url_arg); +} else if (resolved_test_path) { + // Copy files from test + console.log(`Processing test ${url_arg}`); + files = get_all_files(resolved_test_path) + .filter((file) => !file.path.includes('_')) + .map((file) => { + return { + name: file.name === 'main.svelte' ? 'App.svelte' : file.name, + contents: file.contents + }; + }); } else if (url && is_github_url(url)) { // GitHub repository handling await with_tmp_dir(base_dir, (tmp_dir) => { From 37cd40d2e5d1868810b87705287eedd4f0370bdd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 14:23:27 -0500 Subject: [PATCH 06/71] fix: more conservative assignment_value_stale warnings (#17574) --- .changeset/plain-dancers-double.md | 5 ++++ .../client/visitors/AssignmentExpression.js | 5 +++- packages/svelte/src/compiler/phases/scope.js | 8 ++++++ .../svelte/src/internal/client/dev/assign.js | 3 ++- .../_config.js | 25 ++++++++----------- .../main.svelte | 11 ++++++++ 6 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 .changeset/plain-dancers-double.md diff --git a/.changeset/plain-dancers-double.md b/.changeset/plain-dancers-double.md new file mode 100644 index 0000000000..ab47b8e4c7 --- /dev/null +++ b/.changeset/plain-dancers-double.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more conservative assignment_value_stale warnings diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 731569aaae..0f6a619357 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -162,7 +162,10 @@ function build_assignment(operator, left, right, context) { // will be pushed to. we do this by transforming it to something like // `$.assign_nullish(object, 'items', [])` let should_transform = - dev && path.at(-1) !== 'ExpressionStatement' && is_non_coercive_operator(operator); + dev && + path.at(-1) !== 'ExpressionStatement' && + is_non_coercive_operator(operator) && + !context.state.scope.evaluate(right).is_primitive; // special case — ignore `onclick={() => (...)}` if ( diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 2a0c76f756..52efd93210 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -228,6 +228,13 @@ class Evaluation { */ is_number = true; + /** + * True if the value is known to be a primitive + * @readonly + * @type {boolean} + */ + is_primitive = true; + /** * True if the value is known to be a function * @readonly @@ -577,6 +584,7 @@ class Evaluation { if (value === UNKNOWN) { this.has_unknown = true; + this.is_primitive = false; } } diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js index d9ef7497d5..3b48e736b5 100644 --- a/packages/svelte/src/internal/client/dev/assign.js +++ b/packages/svelte/src/internal/client/dev/assign.js @@ -1,3 +1,4 @@ +import { STATE_SYMBOL } from '#client/constants'; import { sanitize_location } from '../../../utils.js'; import { untrack } from '../runtime.js'; import * as w from '../warnings.js'; @@ -10,7 +11,7 @@ import * as w from '../warnings.js'; * @param {string} location */ function compare(a, b, property, location) { - if (a !== b) { + if (a !== b && typeof b === 'object' && STATE_SYMBOL in b) { w.assignment_value_stale(property, /** @type {string} */ (sanitize_location(location))); } diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/_config.js b/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/_config.js index 4462f492fa..2ff2634822 100644 --- a/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/_config.js @@ -6,31 +6,26 @@ export default test({ dev: true }, - html: `
x
`, - test({ assert, target, warnings }) { - const btn = target.querySelector('button'); - ok(btn); + const [button1, button2, button3] = target.querySelectorAll('button'); + ok(button1); - flushSync(() => btn.click()); - assert.htmlEqual( - target.innerHTML, - `
x
` - ); + flushSync(() => button1.click()); + assert.htmlEqual(button1.innerHTML, `items: []`); - flushSync(() => btn.click()); - assert.htmlEqual( - target.innerHTML, - `
x
` - ); + flushSync(() => button1.click()); + assert.htmlEqual(button1.innerHTML, `items: [0]`); const input = target.querySelector('input'); ok(input); input.checked = true; flushSync(() => input.dispatchEvent(new Event('change', { bubbles: true }))); + flushSync(() => button2.click()); + flushSync(() => button3.click()); + assert.deepEqual(warnings, [ - 'Assignment to `items` property (main.svelte:9:24) will evaluate to the right-hand side, not the value of `items` following the assignment. This may result in unexpected behaviour.' + 'Assignment to `items` property (main.svelte:17:24) will evaluate to the right-hand side, not the value of `items` following the assignment. This may result in unexpected behaviour.' ]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/main.svelte b/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/main.svelte index a79fe873b7..02320a3128 100644 --- a/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/proxy-coercive-assignment-warning/main.svelte @@ -1,9 +1,17 @@ + From ece2e83eb9d0e2a97d7db50d41f68ce2edfb697d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 16:09:32 -0500 Subject: [PATCH 07/71] fix: increment signal versions when discarding forks (#17577) * fix: always update an UNINITIALIZED derived on read Co-authored-by: David Roizenman * obsolete comments * rename test * add tests, fix * revert * Update .changeset/vast-hornets-draw.md --------- Co-authored-by: David Roizenman --- .changeset/vast-hornets-draw.md | 5 +++ .../src/internal/client/reactivity/batch.js | 7 ++++ .../fork-derived-uncached-1/_config.js | 40 +++++++++++++++++++ .../fork-derived-uncached-1/main.svelte | 23 +++++++++++ .../fork-derived-uncached-2/_config.js | 38 ++++++++++++++++++ .../fork-derived-uncached-2/main.svelte | 23 +++++++++++ .../fork-derived-uncached-3/_config.js | 32 +++++++++++++++ .../fork-derived-uncached-3/main.svelte | 23 +++++++++++ 8 files changed, 191 insertions(+) create mode 100644 .changeset/vast-hornets-draw.md create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/main.svelte diff --git a/.changeset/vast-hornets-draw.md b/.changeset/vast-hornets-draw.md new file mode 100644 index 0000000000..256439e9da --- /dev/null +++ b/.changeset/vast-hornets-draw.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: increment signal versions when discarding forks diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 134ebe53a2..d82f965541 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -972,6 +972,13 @@ export function fork(fn) { await settled; }, discard: () => { + // cause any MAYBE_DIRTY deriveds to update + // if they depend on things thath changed + // inside the discarded fork + for (var source of batch.current.keys()) { + source.wv = increment_write_version(); + } + if (!committed && batches.has(batch)) { batches.delete(batch); batch.discard(); diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/_config.js b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/_config.js new file mode 100644 index 0000000000..fb50ceb9b1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/_config.js @@ -0,0 +1,40 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + async test({ assert, target }) { + const [fork, toggle, increment] = target.querySelectorAll('button'); + + // derived is first evaluated in block effect, then discarded + flushSync(() => fork.click()); + + // should not throw "Cannot convert a Symbol value to a string" due to cached UNINITIALIZED from first fork + flushSync(() => fork.click()); + + // should not reflect the temporary change to `clicks` inside the fork + flushSync(() => toggle.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

0

+ ` + ); + + flushSync(() => increment.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/main.svelte new file mode 100644 index 0000000000..08ebaba25a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-1/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + +{#if show} +

{derived}

+{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/_config.js b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/_config.js new file mode 100644 index 0000000000..21ab140fc0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/_config.js @@ -0,0 +1,38 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + async test({ assert, target }) { + const [fork, toggle, increment] = target.querySelectorAll('button'); + + // derived is first evaluated in block effect, then discarded + flushSync(() => fork.click()); + + // should not reflect the temporary change to `clicks` inside the fork + // or throw "Cannot convert a Symbol value to a string" due to cached UNINITIALIZED + flushSync(() => toggle.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

0

+ ` + ); + + flushSync(() => increment.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/main.svelte new file mode 100644 index 0000000000..08ebaba25a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-2/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + +{#if show} +

{derived}

+{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/_config.js b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/_config.js new file mode 100644 index 0000000000..4827aa99f3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + async test({ assert, target }) { + const [fork, toggle, increment] = target.querySelectorAll('button'); + + // initialize derived by showing it + flushSync(() => toggle.click()); + flushSync(() => toggle.click()); + + // increment clicks + flushSync(() => increment.click()); + + // update derived, but without writing to `derived.v` + flushSync(() => fork.click()); + + // show derived + flushSync(() => toggle.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/main.svelte new file mode 100644 index 0000000000..08ebaba25a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-uncached-3/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + +{#if show} +

{derived}

+{/if} + From 3608b3c8691cf855ec2cdeb623b904d2b95eddcc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 16:59:40 -0500 Subject: [PATCH 08/71] fix: preserve old dependencies when updating reaction inside fork (#17579) Co-authored-by: David Roizenman --- .changeset/fuzzy-spies-love.md | 5 ++++ .../svelte/src/internal/client/runtime.js | 18 ++++++++++--- .../_config.js | 25 +++++++++++++++++ .../main.svelte | 27 +++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .changeset/fuzzy-spies-love.md create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/main.svelte diff --git a/.changeset/fuzzy-spies-love.md b/.changeset/fuzzy-spies-love.md new file mode 100644 index 0000000000..9129d986bc --- /dev/null +++ b/.changeset/fuzzy-spies-love.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: preserve old dependencies when updating reaction inside fork diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 2075081f96..70eeabb789 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -43,7 +43,13 @@ import { set_dev_current_component_function, set_dev_stack } from './context.js'; -import { Batch, batch_values, flushSync, schedule_effect } from './reactivity/batch.js'; +import { + Batch, + batch_values, + current_batch, + flushSync, + schedule_effect +} from './reactivity/batch.js'; import { handle_error } from './error-handling.js'; import { UNINITIALIZED } from '../../constants.js'; import { captured_signals } from './legacy.js'; @@ -249,10 +255,16 @@ export function update_reaction(reaction) { var result = fn(); 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; - remove_reactions(reaction, skipped_deps); + if (!is_fork) { + remove_reactions(reaction, skipped_deps); + } if (deps !== null && skipped_deps > 0) { deps.length = skipped_deps + new_deps.length; @@ -268,7 +280,7 @@ export function update_reaction(reaction) { (deps[i].reactions ??= []).push(reaction); } } - } else if (deps !== null && skipped_deps < deps.length) { + } else if (!is_fork && deps !== null && skipped_deps < deps.length) { remove_reactions(reaction, skipped_deps); deps.length = skipped_deps; } diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/_config.js b/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/_config.js new file mode 100644 index 0000000000..c5db69f7d4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + async test({ assert, target }) { + const [fork_btn, _toggle_btn, inc_count_1_btn] = target.querySelectorAll('button'); + const p = /** @type {HTMLElement} */ (target.querySelector('p')); + + assert.equal(p.textContent, '0'); + + // Trigger derived to re-evaluate during fork and switch to tracking count_2 + flushSync(() => { + fork_btn.click(); + }); + + assert.equal(p.textContent, '0'); + + flushSync(() => { + inc_count_1_btn.click(); + }); + + assert.equal(p.textContent, '1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/main.svelte b/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/main.svelte new file mode 100644 index 0000000000..800c82b56b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/fork-derived-dependency-rollback/main.svelte @@ -0,0 +1,27 @@ + + + +{#if count} +{/if} + + + + + + + +

{count}

From 1c131f11ca6a49d1ffba07d3d02bb895c668ef00 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 19:00:13 -0500 Subject: [PATCH 09/71] fix: reset effects inside skipped branches (#17581) * fix: prevent reactivity loss during fork fixes #17197, fixes #17304, fixes #17301, fixes #17309 * add samples * add changeset * fix var casing in tests * don't remove reactions during fork * add sample for derived dep tracking in fork * fix sample type check error * set derived.v on first eval in fork * add sample for derived.v remaining UNINITIALIZED * lost current_batch import in runtime.js * Delete how * Update packages/svelte/src/internal/client/reactivity/deriveds.js * delete test in favour of #17577 * extract runtime.js changes into separate PR * alternative approach * revert * clear skipped branches when deferring * fix * fix * changeset * rename test * update test * unused test --------- Co-authored-by: David Roizenman Co-authored-by: Paolo Ricciuti Co-authored-by: Tee Ming --- .changeset/common-boats-travel.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 24 +++++++++++++++++++ .../samples/async-skipped-branch/_config.js | 21 ++++++++++++++++ .../samples/async-skipped-branch/main.svelte | 22 +++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 .changeset/common-boats-travel.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-skipped-branch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-skipped-branch/main.svelte diff --git a/.changeset/common-boats-travel.md b/.changeset/common-boats-travel.md new file mode 100644 index 0000000000..bd3012bbf2 --- /dev/null +++ b/.changeset/common-boats-travel.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reset effects inside skipped branches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index d82f965541..2b6e84889b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -171,6 +171,10 @@ export class Batch { if (this.is_deferred()) { this.#defer_effects(render_effects); this.#defer_effects(effects); + + for (const e of this.skipped_effects) { + reset_branch(e); + } } else { // append/remove branches for (const fn of this.#commit_callbacks) fn(); @@ -881,6 +885,26 @@ export function eager(fn) { return value; } +/** + * Mark all the effects inside a skipped branch CLEAN, so that + * they can be correctly rescheduled later + * @param {Effect} effect + */ +function reset_branch(effect) { + // clean branch = nothing dirty inside, no need to traverse further + if ((effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) !== 0) { + return; + } + + set_signal_status(effect, CLEAN); + + var e = effect.first; + while (e !== null) { + reset_branch(e); + e = e.next; + } +} + /** * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. * This is useful for speculatively loading data (for example) when you suspect that diff --git a/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/_config.js new file mode 100644 index 0000000000..9741b1e836 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/_config.js @@ -0,0 +1,21 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + async test({ assert, target }) { + const [fork_btn, counter_btn] = target.querySelectorAll('button'); + + flushSync(() => { + fork_btn.click(); + }); + + assert.equal(counter_btn.textContent, '0'); + + flushSync(() => { + counter_btn.click(); + }); + + assert.equal(counter_btn.textContent, '1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/main.svelte new file mode 100644 index 0000000000..163609ac58 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-skipped-branch/main.svelte @@ -0,0 +1,22 @@ + + + + +{#if show} + hi +{:else} + {#if show || !show} + + {/if} +{/if} + +{#if show_async} + {await new Promise(() => {})} +{/if} From 2d62ffee9c2396c05f65c677c9b298a7fcfb4020 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 29 Jan 2026 01:28:39 +0100 Subject: [PATCH 10/71] fix: throw for unset `createContext` get on the server (#17580) * fix: throw for unset `createContext` get on the server * smol tweak --------- Co-authored-by: Rich Harris --- .changeset/better-groups-swim.md | 5 +++++ packages/svelte/src/internal/server/context.js | 12 +++++++++++- .../samples/context-not-set-throws/_config.js | 5 +++++ .../samples/context-not-set-throws/main.svelte | 9 +++++++++ packages/svelte/tests/server-side-rendering/test.ts | 8 ++++++-- 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .changeset/better-groups-swim.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/main.svelte diff --git a/.changeset/better-groups-swim.md b/.changeset/better-groups-swim.md new file mode 100644 index 0000000000..0287bb142f --- /dev/null +++ b/.changeset/better-groups-swim.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: throw for unset `createContext` get on the server diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 7779da4c1d..6a7dc1f883 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -17,7 +17,17 @@ export function set_ssr_context(v) { */ export function createContext() { const key = {}; - return [() => getContext(key), (context) => setContext(key, context)]; + + return [ + () => { + if (!hasContext(key)) { + e.missing_context(); + } + + return getContext(key); + }, + (context) => setContext(key, context) + ]; } /** diff --git a/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/_config.js b/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/_config.js new file mode 100644 index 0000000000..575844faf6 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + error: 'missing_context' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/main.svelte b/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/main.svelte new file mode 100644 index 0000000000..c52ad927c7 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context-not-set-throws/main.svelte @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index 2bfc84c7a1..ca98fdd9aa 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -12,6 +12,7 @@ import { assert_html_equal_with_options } from '../html_equal.js'; import { suite_with_variants, type BaseTest } from '../suite.js'; import type { CompileOptions } from '#compiler'; import { seen } from '../../src/internal/server/dev.js'; +import type { SyncRenderOutput } from '#server'; interface SSRTest extends BaseTest { mode?: ('sync' | 'async')[]; @@ -76,6 +77,9 @@ const { test, run } = suite_with_variants Date: Wed, 28 Jan 2026 20:53:28 -0500 Subject: [PATCH 11/71] Version Packages (#17564) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/better-groups-swim.md | 5 ----- .changeset/common-boats-travel.md | 5 ----- .changeset/fuzzy-spies-love.md | 5 ----- .changeset/loose-sloths-guess.md | 5 ----- .changeset/plain-dancers-double.md | 5 ----- .changeset/shaggy-phones-laugh.md | 5 ----- .changeset/sweet-rings-watch.md | 5 ----- .changeset/vast-hornets-draw.md | 5 ----- packages/svelte/CHANGELOG.md | 22 ++++++++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 11 files changed, 24 insertions(+), 42 deletions(-) delete mode 100644 .changeset/better-groups-swim.md delete mode 100644 .changeset/common-boats-travel.md delete mode 100644 .changeset/fuzzy-spies-love.md delete mode 100644 .changeset/loose-sloths-guess.md delete mode 100644 .changeset/plain-dancers-double.md delete mode 100644 .changeset/shaggy-phones-laugh.md delete mode 100644 .changeset/sweet-rings-watch.md delete mode 100644 .changeset/vast-hornets-draw.md diff --git a/.changeset/better-groups-swim.md b/.changeset/better-groups-swim.md deleted file mode 100644 index 0287bb142f..0000000000 --- a/.changeset/better-groups-swim.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: throw for unset `createContext` get on the server diff --git a/.changeset/common-boats-travel.md b/.changeset/common-boats-travel.md deleted file mode 100644 index bd3012bbf2..0000000000 --- a/.changeset/common-boats-travel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reset effects inside skipped branches diff --git a/.changeset/fuzzy-spies-love.md b/.changeset/fuzzy-spies-love.md deleted file mode 100644 index 9129d986bc..0000000000 --- a/.changeset/fuzzy-spies-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: preserve old dependencies when updating reaction inside fork diff --git a/.changeset/loose-sloths-guess.md b/.changeset/loose-sloths-guess.md deleted file mode 100644 index 450040349d..0000000000 --- a/.changeset/loose-sloths-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': minor ---- - -feat: allow passing `ShadowRootInit` object to custom element `shadow` option diff --git a/.changeset/plain-dancers-double.md b/.changeset/plain-dancers-double.md deleted file mode 100644 index ab47b8e4c7..0000000000 --- a/.changeset/plain-dancers-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: more conservative assignment_value_stale warnings diff --git a/.changeset/shaggy-phones-laugh.md b/.changeset/shaggy-phones-laugh.md deleted file mode 100644 index 8cb711491b..0000000000 --- a/.changeset/shaggy-phones-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: disregard `popover` elements when determining whether an element has content diff --git a/.changeset/sweet-rings-watch.md b/.changeset/sweet-rings-watch.md deleted file mode 100644 index 8ce11b2672..0000000000 --- a/.changeset/sweet-rings-watch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: fire introstart/outrostart events after delay, if specified diff --git a/.changeset/vast-hornets-draw.md b/.changeset/vast-hornets-draw.md deleted file mode 100644 index 256439e9da..0000000000 --- a/.changeset/vast-hornets-draw.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: increment signal versions when discarding forks diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index af503dbb20..7abc4fb302 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,27 @@ # svelte +## 5.49.0 + +### Minor Changes + +- feat: allow passing `ShadowRootInit` object to custom element `shadow` option ([#17088](https://github.com/sveltejs/svelte/pull/17088)) + +### Patch Changes + +- fix: throw for unset `createContext` get on the server ([#17580](https://github.com/sveltejs/svelte/pull/17580)) + +- fix: reset effects inside skipped branches ([#17581](https://github.com/sveltejs/svelte/pull/17581)) + +- fix: preserve old dependencies when updating reaction inside fork ([#17579](https://github.com/sveltejs/svelte/pull/17579)) + +- fix: more conservative assignment_value_stale warnings ([#17574](https://github.com/sveltejs/svelte/pull/17574)) + +- fix: disregard `popover` elements when determining whether an element has content ([#17367](https://github.com/sveltejs/svelte/pull/17367)) + +- fix: fire introstart/outrostart events after delay, if specified ([#17567](https://github.com/sveltejs/svelte/pull/17567)) + +- fix: increment signal versions when discarding forks ([#17577](https://github.com/sveltejs/svelte/pull/17577)) + ## 5.48.5 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index ace4c9bd08..699473ecc6 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.48.5", + "version": "5.49.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 4338ba83ae..9be1ccff87 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.48.5'; +export const VERSION = '5.49.0'; export const PUBLIC_VERSION = '5'; From 82fde88780647ac9699d9dec4108580f1865e55b Mon Sep 17 00:00:00 2001 From: Manuel <30698007+manuel3108@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:54:29 +0100 Subject: [PATCH 12/71] fix: `print()` multiline behaviour (#17319) * way cleaner new line handling for base_elements * fix * more fixes * use esrap preview * changeset * bump `esrap` * update snapshot * update more snapshots * Update .changeset/rich-zoos-walk.md --------- Co-authored-by: Rich Harris --- .changeset/rich-zoos-walk.md | 5 ++ packages/svelte/package.json | 2 +- packages/svelte/src/compiler/print/index.js | 64 ++++--------------- .../output.svelte | 2 +- .../output.svelte | 2 +- .../output.svelte | 2 +- .../print/samples/formatting/input.svelte | 9 ++- .../print/samples/formatting/output.svelte | 34 +++++++++- .../print/samples/html-document/output.svelte | 4 ++ .../samples/style-directive/output.svelte | 5 +- .../samples/svelte-fragment/output.svelte | 1 + .../print/samples/svelte-head/output.svelte | 1 + .../_expected/client/index.svelte.js | 2 - .../_expected/server/index.svelte.js | 1 - .../_expected/client/index.svelte.js | 2 - .../_expected/server/index.svelte.js | 2 - .../_expected/client/index.svelte.js | 1 - pnpm-lock.yaml | 10 +-- 18 files changed, 75 insertions(+), 74 deletions(-) create mode 100644 .changeset/rich-zoos-walk.md diff --git a/.changeset/rich-zoos-walk.md b/.changeset/rich-zoos-walk.md new file mode 100644 index 0000000000..fc540d7123 --- /dev/null +++ b/.changeset/rich-zoos-walk.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: properly separate multiline html blocks from each other in `print()` diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 699473ecc6..d7dc67d528 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -176,7 +176,7 @@ "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", - "esrap": "^2.2.1", + "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index 4aed743dba..094c578b83 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -115,57 +115,17 @@ function base_element(node, context) { const is_doctype_node = node.name.toLowerCase() === '!doctype'; const is_self_closing = is_void(node.name) || (node.type === 'Component' && node.fragment.nodes.length === 0); - let multiline_content = false; if (is_doctype_node) child_context.write(`>`); else if (is_self_closing) { child_context.write(`${multiline_attributes ? '' : ' '}/>`); } else { child_context.write('>'); - - // Process the element's content in a separate context for measurement - const content_context = child_context.new(); - const allow_inline_content = child_context.measure() < LINE_BREAK_THRESHOLD; - block(content_context, node.fragment, allow_inline_content); - - // Determine if content should be formatted on multiple lines - multiline_content = content_context.measure() > LINE_BREAK_THRESHOLD; - - if (multiline_content) { - child_context.newline(); - - // Only indent if attributes are inline and content itself isn't already multiline - const should_indent = !multiline_attributes && !content_context.multiline; - if (should_indent) { - child_context.indent(); - } - - child_context.append(content_context); - - if (should_indent) { - child_context.dedent(); - } - - child_context.newline(); - } else { - child_context.append(content_context); - } - + block(child_context, node.fragment, true); child_context.write(``); } - const break_line_after = child_context.measure() > LINE_BREAK_THRESHOLD; - - if ((multiline_content || multiline_attributes) && !context.empty()) { - context.newline(); - } - context.append(child_context); - - if (is_self_closing) return; - if (multiline_content || multiline_attributes || break_line_after) { - context.newline(); - } } /** @type {Visitors} */ @@ -412,6 +372,8 @@ const svelte_visitors = { } } else { sequence.push(child_node); + + if (child_node.type === 'RegularElement') flush(); } } @@ -420,18 +382,20 @@ const svelte_visitors = { let multiline = false; let width = 0; - const child_contexts = items.map((sequence) => { - const child_context = context.new(); + const child_contexts = items + .filter((x) => x.length > 0) + .map((sequence) => { + const child_context = context.new(); - for (const node of sequence) { - child_context.visit(node); - multiline ||= child_context.multiline; - } + for (const node of sequence) { + child_context.visit(node); + multiline ||= child_context.multiline; + } - width += child_context.measure(); + width += child_context.measure(); - return child_context; - }); + return child_context; + }); multiline ||= width > LINE_BREAK_THRESHOLD; diff --git a/packages/svelte/tests/migrate/samples/impossible-migrate-$derived-derived-var-3/output.svelte b/packages/svelte/tests/migrate/samples/impossible-migrate-$derived-derived-var-3/output.svelte index 26012e1115..90d2e2e036 100644 --- a/packages/svelte/tests/migrate/samples/impossible-migrate-$derived-derived-var-3/output.svelte +++ b/packages/svelte/tests/migrate/samples/impossible-migrate-$derived-derived-var-3/output.svelte @@ -4,4 +4,4 @@ - + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/impossible-migrate-slot-change-name/output.svelte b/packages/svelte/tests/migrate/samples/impossible-migrate-slot-change-name/output.svelte index 328966b63b..20e41646b3 100644 --- a/packages/svelte/tests/migrate/samples/impossible-migrate-slot-change-name/output.svelte +++ b/packages/svelte/tests/migrate/samples/impossible-migrate-slot-change-name/output.svelte @@ -3,4 +3,4 @@ let body; - + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/impossible-migrate-slot-non-identifier/output.svelte b/packages/svelte/tests/migrate/samples/impossible-migrate-slot-non-identifier/output.svelte index 1e763577df..49e9462768 100644 --- a/packages/svelte/tests/migrate/samples/impossible-migrate-slot-non-identifier/output.svelte +++ b/packages/svelte/tests/migrate/samples/impossible-migrate-slot-non-identifier/output.svelte @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/packages/svelte/tests/print/samples/formatting/input.svelte b/packages/svelte/tests/print/samples/formatting/input.svelte index 9b1898e9c8..43c3b363cb 100644 --- a/packages/svelte/tests/print/samples/formatting/input.svelte +++ b/packages/svelte/tests/print/samples/formatting/input.svelte @@ -1 +1,8 @@ -

{m.hello_world({ name: 'SvelteKit User' })}

If you use VSCode, install the Sherlock i18n extensionfor a better i18n experience.

+ + +

{m.hello_world({ name: 'SvelteKit User' })}

If you use VSCode, install the Sherlock i18n extensionfor a better i18n experience.

+ +
+ + + diff --git a/packages/svelte/tests/print/samples/formatting/output.svelte b/packages/svelte/tests/print/samples/formatting/output.svelte index 7b40642580..ba871c66a5 100644 --- a/packages/svelte/tests/print/samples/formatting/output.svelte +++ b/packages/svelte/tests/print/samples/formatting/output.svelte @@ -4,18 +4,46 @@

{m.hello_world({ name: 'SvelteKit User' })}

+
+

If you use VSCode, install the - Sherlock i18n extension - + >Sherlock i18n extension + for a better i18n experience.

+ + +
+ + +
+
+ + + + + + + + + diff --git a/packages/svelte/tests/print/samples/html-document/output.svelte b/packages/svelte/tests/print/samples/html-document/output.svelte index 765f9ca84b..94ef78d0a3 100644 --- a/packages/svelte/tests/print/samples/html-document/output.svelte +++ b/packages/svelte/tests/print/samples/html-document/output.svelte @@ -1,12 +1,16 @@ + + + Svelte App +
Hello World
diff --git a/packages/svelte/tests/print/samples/style-directive/output.svelte b/packages/svelte/tests/print/samples/style-directive/output.svelte index 5aa3a6dcdb..27c12c5d47 100644 --- a/packages/svelte/tests/print/samples/style-directive/output.svelte +++ b/packages/svelte/tests/print/samples/style-directive/output.svelte @@ -1,8 +1,7 @@
...
+
- ... -
+>... diff --git a/packages/svelte/tests/print/samples/svelte-fragment/output.svelte b/packages/svelte/tests/print/samples/svelte-fragment/output.svelte index eb80023626..73a4451ed8 100644 --- a/packages/svelte/tests/print/samples/svelte-fragment/output.svelte +++ b/packages/svelte/tests/print/samples/svelte-fragment/output.svelte @@ -4,6 +4,7 @@

Hello

+

All rights reserved.

Copyright (c) 2019 Svelte Industries

diff --git a/packages/svelte/tests/print/samples/svelte-head/output.svelte b/packages/svelte/tests/print/samples/svelte-head/output.svelte index 68d352260e..da4345d731 100644 --- a/packages/svelte/tests/print/samples/svelte-head/output.svelte +++ b/packages/svelte/tests/print/samples/svelte-head/output.svelte @@ -1,5 +1,6 @@ Hello world! + yes1 = await $.async_derived(() => 1), async () => yes2 = await $.async_derived(async () => foo(await 1)), - () => no1 = $.derived(async () => { return await 1; }), @@ -33,7 +32,6 @@ export default function Async_in_derived($$anchor, $$props) { var promises = $.run([ async () => yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(), async () => yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(), - () => no1 = $.derived(() => (async () => { return await 1; })()), diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js index 1fd184fa79..58358b3ded 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/server/index.svelte.js @@ -8,7 +8,6 @@ export default function Async_in_derived($$renderer, $$props) { var $$promises = $$renderer.run([ async () => yes1 = await 1, async () => yes2 = foo(await 1), - () => no1 = (async () => { return await 1; })(), diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index 218951b836..762a23754c 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -14,7 +14,6 @@ export default function Function_prop_no_getter($$anchor) { onmousedown: () => $.set(count, $.get(count) + 1), onmouseup, onmouseenter: () => $.set(count, plusOne($.get(count)), true), - children: ($$anchor, $$slotProps) => { $.next(); @@ -23,7 +22,6 @@ export default function Function_prop_no_getter($$anchor) { $.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ''}`)); $.append($$anchor, text); }, - $$slots: { default: true } }); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js index 855ae30d21..e2936b7f6e 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js @@ -13,11 +13,9 @@ export default function Function_prop_no_getter($$renderer) { onmousedown: () => count += 1, onmouseup, onmouseenter: () => count = plusOne(count), - children: ($$renderer) => { $$renderer.push(`clicks: ${$.escape(count)}`); }, - $$slots: { default: true } }); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js index d4034dc55d..792d5421e1 100644 --- a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js @@ -6,7 +6,6 @@ var root = $.from_tree( [ ['h1', null, 'hello'], ' ', - [ 'div', { class: 'potato' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b616fc0577..b14dc104ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^1.2.1 version: 1.2.1 esrap: - specifier: ^2.2.1 - version: 2.2.1 + specifier: ^2.2.2 + version: 2.2.2 is-reference: specifier: ^3.0.3 version: 3.0.3 @@ -1356,8 +1356,8 @@ packages: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} engines: {node: '>=0.10'} - esrap@2.2.1: - resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} + esrap@2.2.2: + resolution: {integrity: sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -3720,7 +3720,7 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.1: + esrap@2.2.2: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 From ffd65e90febc29feaca48e142126a4087fcaca9f Mon Sep 17 00:00:00 2001 From: 7nik Date: Thu, 29 Jan 2026 04:56:05 +0200 Subject: [PATCH 13/71] chore: allow testing in production env (#16840) * allow testing in production env * oops --- .../samples/production/_config.js | 5 ++ .../samples/production/main.svelte | 7 ++ packages/svelte/tests/suite.ts | 66 +++++++++++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/production/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/production/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/production/_config.js b/packages/svelte/tests/runtime-runes/samples/production/_config.js new file mode 100644 index 0000000000..ce36c9d95b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/production/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + production: true +}); diff --git a/packages/svelte/tests/runtime-runes/samples/production/main.svelte b/packages/svelte/tests/runtime-runes/samples/production/main.svelte new file mode 100644 index 0000000000..d9cff745d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/production/main.svelte @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts index bbd252b8e1..72c40f526b 100644 --- a/packages/svelte/tests/suite.ts +++ b/packages/svelte/tests/suite.ts @@ -1,9 +1,11 @@ import fs from 'node:fs'; -import { it } from 'vitest'; +import { it, vi } from 'vitest'; export interface BaseTest { skip?: boolean; solo?: boolean; + /** Set `DEV` to `false` */ + production?: boolean; } /** @@ -27,11 +29,31 @@ export function suite(fn: (config: Test, test_dir: string return { test: (config: Test) => config, run: async (cwd: string, samples_dir = 'samples') => { + const production_tests: Array<[Function, string, Function]> = []; + await for_each_dir(cwd, samples_dir, (config, dir) => { let it_fn = config.skip ? it.skip : config.solo ? it.only : it; - it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); + if (config.production) { + production_tests.push([it_fn, dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)]); + } else { + it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); + } }); + + let mocked = false; + for (const [it, name, test] of production_tests) { + it(name, () => { + if (!mocked) { + vi.doMock('esm-env', async (importEnv) => ({ + ...(await importEnv()), + DEV: false + })); + mocked = true; + } + return test(); + }); + } } }; } @@ -45,6 +67,8 @@ export function suite_with_variants config, run: async (cwd: string, samples_dir = 'samples') => { + const production_tests: Array<[Function, string, Function]> = []; + await for_each_dir(cwd, samples_dir, (config, dir) => { let called_common = false; let common: any = undefined; @@ -57,15 +81,35 @@ export function suite_with_variants { + const test = async () => { if (!called_common) { called_common = true; common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`); } return fn(config, `${cwd}/${samples_dir}/${dir}`, variant, common); - }); + }; + + if (config.production) { + production_tests.push([it_fn, `${dir} (${variant})`, test]); + } else { + it_fn(`${dir} (${variant})`, test); + } } }); + + let mocked = false; + for (const [it, name, test] of production_tests) { + it(name, () => { + if (!mocked) { + vi.doMock('esm-env', async (importEnv) => ({ + ...(await importEnv()), + DEV: false + })); + mocked = true; + } + return test(); + }); + } } }; } @@ -108,3 +152,17 @@ export function assert_ok(value: any): asserts value { throw new Error(`Expected truthy value, got ${value}`); } } + +function run_in_production(fn: (...args: any[]) => void | Promise) { + return async (...args: any[]) => { + vi.doMock('esm-env', async (importEnv) => ({ + ...(await importEnv()), + DEV: false + })); + try { + await fn(...args); + } finally { + vi.doUnmock('esm-env'); + } + }; +} From 26b09ec420bca70d4a718f61dd83393cc2af7b72 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 28 Jan 2026 23:12:48 -0500 Subject: [PATCH 14/71] chore: bump playwright (#17565) * chore: bump playwright * maybe this will help somehow? * err whatever * fix --- package.json | 2 +- packages/svelte/package.json | 2 +- .../samples/head-scripts/_config.js | 2 +- packages/svelte/tests/runtime-browser/test.ts | 1 + pnpm-lock.yaml | 30 +++++++++---------- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 24be8bd2bc..e7faf26c13 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "eslint-plugin-lube": "^0.4.3", "eslint-plugin-svelte": "^3.11.0", "jsdom": "25.0.1", - "playwright": "^1.46.1", + "playwright": "^1.58.0", "prettier": "^3.2.4", "prettier-plugin-svelte": "^3.4.0", "svelte": "workspace:^", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d7dc67d528..389488dcda 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -150,7 +150,7 @@ }, "devDependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "@playwright/test": "^1.46.1", + "@playwright/test": "^1.58.0", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.4", diff --git a/packages/svelte/tests/runtime-browser/samples/head-scripts/_config.js b/packages/svelte/tests/runtime-browser/samples/head-scripts/_config.js index 3ff1bf7286..4f0e53925b 100644 --- a/packages/svelte/tests/runtime-browser/samples/head-scripts/_config.js +++ b/packages/svelte/tests/runtime-browser/samples/head-scripts/_config.js @@ -4,7 +4,7 @@ export default test({ mode: ['client'], async test({ assert, window }) { // wait the script to load (maybe there a better way) - await new Promise((resolve) => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 100)); assert.htmlEqual( window.document.body.innerHTML, `
123
` diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts index 597b2909dc..525f9ba89f 100644 --- a/packages/svelte/tests/runtime-browser/test.ts +++ b/packages/svelte/tests/runtime-browser/test.ts @@ -197,6 +197,7 @@ async function run_test( const page = await browser.newPage(); page.on('console', (message) => { let method = message.type(); + // @ts-ignore if (method === 'warning') method = 'warn'; // @ts-ignore console[method](message.text()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b14dc104ba..6d3e66825c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: 25.0.1 version: 25.0.1 playwright: - specifier: ^1.46.1 - version: 1.46.1 + specifier: ^1.58.0 + version: 1.58.0 prettier: specifier: ^3.2.4 version: 3.2.4 @@ -115,8 +115,8 @@ importers: specifier: ^0.3.25 version: 0.3.31 '@playwright/test': - specifier: ^1.46.1 - version: 1.46.1 + specifier: ^1.58.0 + version: 1.58.0 '@rollup/plugin-commonjs': specifier: ^28.0.1 version: 28.0.1(rollup@4.52.5) @@ -667,8 +667,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.46.1': - resolution: {integrity: sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==} + '@playwright/test@1.58.0': + resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} engines: {node: '>=18'} hasBin: true @@ -1937,13 +1937,13 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - playwright-core@1.46.1: - resolution: {integrity: sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==} + playwright-core@1.58.0: + resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} engines: {node: '>=18'} hasBin: true - playwright@1.46.1: - resolution: {integrity: sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==} + playwright@1.58.0: + resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} engines: {node: '>=18'} hasBin: true @@ -2973,9 +2973,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.46.1': + '@playwright/test@1.58.0': dependencies: - playwright: 1.46.1 + playwright: 1.58.0 '@polka/url@1.0.0-next.25': {} @@ -4253,11 +4253,11 @@ snapshots: pify@4.0.1: {} - playwright-core@1.46.1: {} + playwright-core@1.58.0: {} - playwright@1.46.1: + playwright@1.58.0: dependencies: - playwright-core: 1.46.1 + playwright-core: 1.58.0 optionalDependencies: fsevents: 2.3.2 From 704d0cd765d1c7d92601888e95898fcd99ce550c Mon Sep 17 00:00:00 2001 From: 7nik Date: Thu, 29 Jan 2026 23:00:39 +0200 Subject: [PATCH 15/71] chore: allow testing in production env 2 (#17590) * Revert "chore: allow testing in production env (#16840)" This reverts commit ffd65e90febc29feaca48e142126a4087fcaca9f. * new approach --- .../samples/production-internals/Child.svelte | 5 ++ .../samples/production-internals/_config.js | 12 ++++ .../samples/production-internals/main.svelte | 19 ++++++ .../samples/production/main.svelte | 0 .../svelte/tests/runtime-production/test.ts | 17 +++++ .../samples/production/_config.js | 5 -- packages/svelte/tests/suite.ts | 66 ++----------------- 7 files changed, 57 insertions(+), 67 deletions(-) create mode 100644 packages/svelte/tests/runtime-production/samples/production-internals/Child.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/production-internals/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/production-internals/main.svelte rename packages/svelte/tests/{runtime-runes => runtime-production}/samples/production/main.svelte (100%) create mode 100644 packages/svelte/tests/runtime-production/test.ts delete mode 100644 packages/svelte/tests/runtime-runes/samples/production/_config.js diff --git a/packages/svelte/tests/runtime-production/samples/production-internals/Child.svelte b/packages/svelte/tests/runtime-production/samples/production-internals/Child.svelte new file mode 100644 index 0000000000..36b548ab8b --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/production-internals/Child.svelte @@ -0,0 +1,5 @@ + + +{foo}; diff --git a/packages/svelte/tests/runtime-production/samples/production-internals/_config.js b/packages/svelte/tests/runtime-production/samples/production-internals/_config.js new file mode 100644 index 0000000000..6ac08f7012 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/production-internals/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ target }) { + let button = target.querySelector('button'); + + button?.click(); + + flushSync(); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/production-internals/main.svelte b/packages/svelte/tests/runtime-production/samples/production-internals/main.svelte new file mode 100644 index 0000000000..0629644a01 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/production-internals/main.svelte @@ -0,0 +1,19 @@ + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/production/main.svelte b/packages/svelte/tests/runtime-production/samples/production/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/production/main.svelte rename to packages/svelte/tests/runtime-production/samples/production/main.svelte diff --git a/packages/svelte/tests/runtime-production/test.ts b/packages/svelte/tests/runtime-production/test.ts new file mode 100644 index 0000000000..1c9a00ab83 --- /dev/null +++ b/packages/svelte/tests/runtime-production/test.ts @@ -0,0 +1,17 @@ +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { runtime_suite, ok } from '../runtime-legacy/shared'; + +vi.mock('esm-env', async (importEnv) => { + return { + ...(await importEnv()), + DEV: false + }; +}); + +const { test, run } = runtime_suite(true); + +export { test, ok }; + +await run(__dirname); diff --git a/packages/svelte/tests/runtime-runes/samples/production/_config.js b/packages/svelte/tests/runtime-runes/samples/production/_config.js deleted file mode 100644 index ce36c9d95b..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/production/_config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { test } from '../../test'; - -export default test({ - production: true -}); diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts index 72c40f526b..bbd252b8e1 100644 --- a/packages/svelte/tests/suite.ts +++ b/packages/svelte/tests/suite.ts @@ -1,11 +1,9 @@ import fs from 'node:fs'; -import { it, vi } from 'vitest'; +import { it } from 'vitest'; export interface BaseTest { skip?: boolean; solo?: boolean; - /** Set `DEV` to `false` */ - production?: boolean; } /** @@ -29,31 +27,11 @@ export function suite(fn: (config: Test, test_dir: string return { test: (config: Test) => config, run: async (cwd: string, samples_dir = 'samples') => { - const production_tests: Array<[Function, string, Function]> = []; - await for_each_dir(cwd, samples_dir, (config, dir) => { let it_fn = config.skip ? it.skip : config.solo ? it.only : it; - if (config.production) { - production_tests.push([it_fn, dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)]); - } else { - it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); - } + it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`)); }); - - let mocked = false; - for (const [it, name, test] of production_tests) { - it(name, () => { - if (!mocked) { - vi.doMock('esm-env', async (importEnv) => ({ - ...(await importEnv()), - DEV: false - })); - mocked = true; - } - return test(); - }); - } } }; } @@ -67,8 +45,6 @@ export function suite_with_variants config, run: async (cwd: string, samples_dir = 'samples') => { - const production_tests: Array<[Function, string, Function]> = []; - await for_each_dir(cwd, samples_dir, (config, dir) => { let called_common = false; let common: any = undefined; @@ -81,35 +57,15 @@ export function suite_with_variants { + it_fn(`${dir} (${variant})`, async () => { if (!called_common) { called_common = true; common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`); } return fn(config, `${cwd}/${samples_dir}/${dir}`, variant, common); - }; - - if (config.production) { - production_tests.push([it_fn, `${dir} (${variant})`, test]); - } else { - it_fn(`${dir} (${variant})`, test); - } + }); } }); - - let mocked = false; - for (const [it, name, test] of production_tests) { - it(name, () => { - if (!mocked) { - vi.doMock('esm-env', async (importEnv) => ({ - ...(await importEnv()), - DEV: false - })); - mocked = true; - } - return test(); - }); - } } }; } @@ -152,17 +108,3 @@ export function assert_ok(value: any): asserts value { throw new Error(`Expected truthy value, got ${value}`); } } - -function run_in_production(fn: (...args: any[]) => void | Promise) { - return async (...args: any[]) => { - vi.doMock('esm-env', async (importEnv) => ({ - ...(await importEnv()), - DEV: false - })); - try { - await fn(...args); - } finally { - vi.doUnmock('esm-env'); - } - }; -} From 5656dd569a36b02e8871d7461631975601e879d1 Mon Sep 17 00:00:00 2001 From: Antonio Bennett <31296212+Antonio-Bennett@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:23:34 -0500 Subject: [PATCH 16/71] fix: handle renderer.run rejections (#17591) * fix: handle renderer run rejections * add test * changeset * simplify * explanatory comment --------- Co-authored-by: Antonio Bennett Co-authored-by: Rich Harris --- .changeset/tidy-zebras-design.md | 5 +++++ packages/svelte/src/internal/server/renderer.js | 6 ++++++ .../runtime-runes/samples/async-error-in-script/_config.js | 6 ++++++ .../samples/async-error-in-script/main.svelte | 7 +++++++ 4 files changed, 24 insertions(+) create mode 100644 .changeset/tidy-zebras-design.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-error-in-script/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-error-in-script/main.svelte diff --git a/.changeset/tidy-zebras-design.md b/.changeset/tidy-zebras-design.md new file mode 100644 index 0000000000..3b18af798f --- /dev/null +++ b/.changeset/tidy-zebras-design.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent unhandled exceptions arising from dangling promises in + +

hello

From ebe583f2bb1d341b9e0474374124796d0f035e5c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 29 Jan 2026 19:28:36 -0500 Subject: [PATCH 17/71] fix: only create async functions in SSR output when necessary (#17593) * fix: only create async functions in SSR output when necessary * actually... * simplify generated code a bit more * simplify --- .changeset/hot-insects-stare.md | 5 +++++ .../server/visitors/RegularElement.js | 20 +++++++++---------- .../server/visitors/SvelteElement.js | 2 +- .../server/visitors/shared/utils.js | 5 ++--- 4 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 .changeset/hot-insects-stare.md diff --git a/.changeset/hot-insects-stare.md b/.changeset/hot-insects-stare.md new file mode 100644 index 0000000000..fb4109c35e --- /dev/null +++ b/.changeset/hot-insects-stare.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: only create async functions in SSR output when necessary diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index 79858b95d9..5b905752f4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -70,8 +70,7 @@ export function RegularElement(node, context) { if (optimiser.expressions.length > 0) { context.state.template.push( create_child_block( - b.block([optimiser.apply(), ...state.init, ...build_template(state.template)]), - true + b.block([optimiser.apply(), ...state.init, ...build_template(state.template)]) ) ); } else { @@ -133,7 +132,7 @@ export function RegularElement(node, context) { if (optimiser.expressions.length > 0) { context.state.template.push( - create_child_block(b.block([optimiser.apply(), ...state.init, statement]), true) + create_child_block(b.block([optimiser.apply(), ...state.init, statement])) ); } else { context.state.template.push(...state.init, statement); @@ -186,7 +185,7 @@ export function RegularElement(node, context) { if (optimiser.expressions.length > 0) { context.state.template.push( - create_child_block(b.block([optimiser.apply(), ...state.init, statement]), true) + create_child_block(b.block([optimiser.apply(), ...state.init, statement])) ); } else { context.state.template.push(...state.init, statement); @@ -236,18 +235,19 @@ export function RegularElement(node, context) { } if (optimiser.is_async()) { - let statement = create_child_block( - b.block([optimiser.apply(), ...state.init, ...build_template(state.template)]), - true - ); + let statements = [...state.init, ...build_template(state.template)]; + + if (optimiser.has_await) { + statements = [create_child_block(b.block([optimiser.apply(), ...statements]))]; + } const blockers = optimiser.blockers(); if (blockers.elements.length > 0) { - statement = create_async_block(b.block([statement]), blockers, false, false); + statements = [create_async_block(b.block(statements), blockers, false, false)]; } - context.state.template.push(statement); + context.state.template.push(...statements); } else { context.state.init.push(...state.init); context.state.template.push(...state.template); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js index 1ac15893f2..75ba323e20 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js @@ -79,7 +79,7 @@ export function SvelteElement(node, context) { ); if (optimiser.expressions.length > 0) { - statement = create_child_block(b.block([optimiser.apply(), statement]), true); + statement = create_child_block(b.block([optimiser.apply(), statement])); } statements.push(statement); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index 4736a7c5da..b02a935ed2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -264,11 +264,10 @@ export function build_getter(node, state) { /** * Creates a `$$renderer.child(...)` expression statement * @param {BlockStatement | Expression} body - * @param {boolean} async * @returns {Statement} */ -export function create_child_block(body, async) { - return b.stmt(b.call('$$renderer.child', b.arrow([b.id('$$renderer')], body, async))); +export function create_child_block(body) { + return b.stmt(b.call('$$renderer.child', b.arrow([b.id('$$renderer')], body, true))); } /** From 8933653fbea644699ca2a2d316f017ad3cdfb269 Mon Sep 17 00:00:00 2001 From: FORMI <239411042+Richman018@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:40:40 +0800 Subject: [PATCH 18/71] fix: merge consecutive text nodes during hydration for large text content (#17587) * fix: merge consecutive text nodes during hydration for large text content Fixes #17582 Browsers automatically split text nodes exceeding 65536 characters into multiple consecutive text nodes during HTML parsing. This causes hydration mismatches when Svelte expects a single text node. The fix merges consecutive text nodes during hydration by: - Detecting when the current node is a text node - Finding all consecutive text node siblings - Merging their content into the first text node - Removing the extra text nodes This restores correct hydration behavior for large text content. * add test, fix * fix * fix * changeset --------- Co-authored-by: Miner Co-authored-by: Rich Harris --- .changeset/deep-bears-see.md | 5 ++ .../src/internal/client/dom/operations.js | 73 ++++++++++++++----- .../src/internal/client/dom/template.js | 6 +- .../hydrate-large-text-node/_config.js | 24 ++++++ .../hydrate-large-text-node/main.svelte | 5 ++ packages/svelte/tests/runtime-browser/test.ts | 2 +- 6 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 .changeset/deep-bears-see.md create mode 100644 packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js create mode 100644 packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte diff --git a/.changeset/deep-bears-see.md b/.changeset/deep-bears-see.md new file mode 100644 index 0000000000..9f59b31d12 --- /dev/null +++ b/.changeset/deep-bears-see.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: merge consecutive large text nodes diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 479c2ba0a5..61a48bcbfb 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -122,6 +122,10 @@ export function child(node, is_text) { return text; } + if (is_text) { + merge_text_nodes(/** @type {Text} */ (child)); + } + set_hydrate_node(child); return child; } @@ -142,14 +146,18 @@ export function first_child(node, is_text = false) { return first; } - // if an {expression} is empty during SSR, there might be no - // text node to hydrate — we must therefore create one - if (is_text && hydrate_node?.nodeType !== TEXT_NODE) { - var text = create_text(); + if (is_text) { + // if an {expression} is empty during SSR, there might be no + // text node to hydrate — we must therefore create one + if (hydrate_node?.nodeType !== TEXT_NODE) { + var text = create_text(); - hydrate_node?.before(text); - set_hydrate_node(text); - return text; + hydrate_node?.before(text); + set_hydrate_node(text); + return text; + } + + merge_text_nodes(/** @type {Text} */ (hydrate_node)); } return hydrate_node; @@ -175,20 +183,24 @@ export function sibling(node, count = 1, is_text = false) { return next_sibling; } - // if a sibling {expression} is empty during SSR, there might be no - // text node to hydrate — we must therefore create one - if (is_text && next_sibling?.nodeType !== TEXT_NODE) { - var text = create_text(); - // If the next sibling is `null` and we're handling text then it's because - // the SSR content was empty for the text, so we need to generate a new text - // node and insert it after the last sibling - if (next_sibling === null) { - last_sibling?.after(text); - } else { - next_sibling.before(text); + if (is_text) { + // if a sibling {expression} is empty during SSR, there might be no + // text node to hydrate — we must therefore create one + if (next_sibling?.nodeType !== TEXT_NODE) { + var text = create_text(); + // If the next sibling is `null` and we're handling text then it's because + // the SSR content was empty for the text, so we need to generate a new text + // node and insert it after the last sibling + if (next_sibling === null) { + last_sibling?.after(text); + } else { + next_sibling.before(text); + } + set_hydrate_node(text); + return text; } - set_hydrate_node(text); - return text; + + merge_text_nodes(/** @type {Text} */ (next_sibling)); } set_hydrate_node(next_sibling); @@ -258,3 +270,24 @@ export function set_attribute(element, key, value = '') { } return element.setAttribute(key, value); } + +/** + * Browsers split text nodes larger than 65536 bytes when parsing. + * For hydration to succeed, we need to stitch them back together + * @param {Text} text + */ +export function merge_text_nodes(text) { + if (/** @type {string} */ (text.nodeValue).length < 65536) { + return; + } + + let next = text.nextSibling; + + while (next !== null && next.nodeType === TEXT_NODE) { + next.remove(); + + /** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue); + + next = text.nextSibling; + } +} diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 0d827218bc..567fbeabf0 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -4,11 +4,13 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydra import { create_text, get_first_child, + get_next_sibling, is_firefox, create_element, create_fragment, create_comment, - set_attribute + set_attribute, + merge_text_nodes } from './operations.js'; import { create_fragment_from_html } from './reconciler.js'; import { active_effect } from '../runtime.js'; @@ -310,6 +312,8 @@ export function text(value = '') { // if an {expression} is empty during SSR, we need to insert an empty text node node.before((node = create_text())); set_hydrate_node(node); + } else { + merge_text_nodes(/** @type {Text} */ (node)); } assign_nodes(node, node); diff --git a/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js new file mode 100644 index 0000000000..3a52b1ccd3 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js @@ -0,0 +1,24 @@ +import { test } from '../../assert'; + +// Browsers split text nodes > 65536 characters into multiple consecutive text nodes +// during HTML parsing. This test verifies that hydration correctly merges them. +const LARGE_TEXT = 'x'.repeat(70000); + +export default test({ + mode: ['hydrate'], + skip_mode: ['client'], + + props: { + text: LARGE_TEXT + }, + + async test({ assert, target }) { + const [p] = target.querySelectorAll('p'); + + // The text content should be preserved after hydration + assert.equal(p.textContent?.trim(), LARGE_TEXT); + // After hydration, there should be only one text node (plus possible comment nodes) + const textNodes = [...p.childNodes].filter((node) => node.nodeType === 3); + assert.equal(textNodes.length, 1, `Expected 1 text node, got ${textNodes.length}`); + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte new file mode 100644 index 0000000000..d65358dabc --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte @@ -0,0 +1,5 @@ + + +

{text}

diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts index 525f9ba89f..54cdc0f8be 100644 --- a/packages/svelte/tests/runtime-browser/test.ts +++ b/packages/svelte/tests/runtime-browser/test.ts @@ -213,7 +213,7 @@ async function run_test( } // uncomment to see what was generated - // fs.writeFileSync(`${test_dir}/_actual.js`, build_result.outputFiles[0].text); + // fs.writeFileSync(`${test_dir}/_output/bundle-${hydrate}.js`, build_result.outputFiles[0].text); const test_result = await page.evaluate( build_result.outputFiles[0].text + ";test.default(document.querySelector('main'))" ); From 92e6721c035d336458ebbe6cb23236ac8e7fd50c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:32:41 -0500 Subject: [PATCH 19/71] Version Packages (#17585) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/deep-bears-see.md | 5 ----- .changeset/hot-insects-stare.md | 5 ----- .changeset/rich-zoos-walk.md | 5 ----- .changeset/tidy-zebras-design.md | 5 ----- packages/svelte/CHANGELOG.md | 12 ++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 .changeset/deep-bears-see.md delete mode 100644 .changeset/hot-insects-stare.md delete mode 100644 .changeset/rich-zoos-walk.md delete mode 100644 .changeset/tidy-zebras-design.md diff --git a/.changeset/deep-bears-see.md b/.changeset/deep-bears-see.md deleted file mode 100644 index 9f59b31d12..0000000000 --- a/.changeset/deep-bears-see.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: merge consecutive large text nodes diff --git a/.changeset/hot-insects-stare.md b/.changeset/hot-insects-stare.md deleted file mode 100644 index fb4109c35e..0000000000 --- a/.changeset/hot-insects-stare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: only create async functions in SSR output when necessary diff --git a/.changeset/rich-zoos-walk.md b/.changeset/rich-zoos-walk.md deleted file mode 100644 index fc540d7123..0000000000 --- a/.changeset/rich-zoos-walk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: properly separate multiline html blocks from each other in `print()` diff --git a/.changeset/tidy-zebras-design.md b/.changeset/tidy-zebras-design.md deleted file mode 100644 index 3b18af798f..0000000000 --- a/.changeset/tidy-zebras-design.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: prevent unhandled exceptions arising from dangling promises in diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js new file mode 100644 index 0000000000..2f371bc6b7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + + assert.deepEqual(logs, ['promise resolved with:', 'some-id']); + + const button = target.querySelector('button'); + button?.click(); + await tick(); + + assert.deepEqual(logs, ['promise resolved with:', 'some-id']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte new file mode 100644 index 0000000000..d5cdd5967e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte @@ -0,0 +1,14 @@ + + +{#if active} + +{/if} + + From baba15ab981a3b090eaabefe3425f5f4ea9a8074 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Thu, 5 Feb 2026 00:31:33 +0800 Subject: [PATCH 22/71] fix: avoid 'node:crypto' cloudflare warnings (#17612) * fix: avoid 'node:crypto' cloudflare warnings * format * changeset * Apply suggestion from @teemingc --- .changeset/violet-pans-know.md | 5 +++++ packages/svelte/src/internal/server/crypto.js | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/violet-pans-know.md diff --git a/.changeset/violet-pans-know.md b/.changeset/violet-pans-know.md new file mode 100644 index 0000000000..59bf1dcaaf --- /dev/null +++ b/.changeset/violet-pans-know.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid Cloudflare warnings about not having the "node:crypto" module diff --git a/packages/svelte/src/internal/server/crypto.js b/packages/svelte/src/internal/server/crypto.js index 8727635481..9bb6ecdd39 100644 --- a/packages/svelte/src/internal/server/crypto.js +++ b/packages/svelte/src/internal/server/crypto.js @@ -12,7 +12,8 @@ export async function sha256(data) { crypto ??= globalThis.crypto?.subtle?.digest ? globalThis.crypto : // @ts-ignore - we don't install node types in the prod build - (await import('node:crypto')).webcrypto; + // don't use 'node:crypto' because static analysers will think we rely on node when we don't + (await import('node:' + 'crypto')).webcrypto; const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data)); From 05229d96823cc88d11c0b4a81b357cdb67f69326 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Thu, 5 Feb 2026 00:32:33 +0800 Subject: [PATCH 23/71] chore: remove SvelteKit data attributes from elements.d.ts (#17613) * Remove SvelteKit data attributes from elements.d.ts Removed SvelteKit specific data attributes from elements.d.ts. * Remove SvelteKit data attributes from elements.d.ts Removed SvelteKit data attributes from elements.d.ts. --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/quiet-buttons-bathe.md | 5 +++++ packages/svelte/elements.d.ts | 17 ----------------- 2 files changed, 5 insertions(+), 17 deletions(-) create mode 100644 .changeset/quiet-buttons-bathe.md diff --git a/.changeset/quiet-buttons-bathe.md b/.changeset/quiet-buttons-bathe.md new file mode 100644 index 0000000000..419a5569b4 --- /dev/null +++ b/.changeset/quiet-buttons-bathe.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +chore: remove SvelteKit data attributes from elements.d.ts diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index fa74124472..885004dd2a 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -851,23 +851,6 @@ export interface HTMLAttributes extends AriaAttributes, D readonly 'bind:offsetWidth'?: number | undefined | null; readonly 'bind:offsetHeight'?: number | undefined | null; - // SvelteKit - 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-preload-code'?: - | true - | '' - | 'eager' - | 'viewport' - | 'hover' - | 'tap' - | 'off' - | undefined - | null; - 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; - 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; - // allow any data- attribute [key: `data-${string}`]: any; From eb63a6bbbaffec07890ae181b02edf4c52e2586a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:52:30 -0500 Subject: [PATCH 24/71] Version Packages (#17614) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/quiet-buttons-bathe.md | 5 ----- .changeset/some-teams-pay.md | 5 ----- .changeset/violet-pans-know.md | 5 ----- .changeset/yummy-insects-wonder.md | 5 ----- packages/svelte/CHANGELOG.md | 12 ++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 .changeset/quiet-buttons-bathe.md delete mode 100644 .changeset/some-teams-pay.md delete mode 100644 .changeset/violet-pans-know.md delete mode 100644 .changeset/yummy-insects-wonder.md diff --git a/.changeset/quiet-buttons-bathe.md b/.changeset/quiet-buttons-bathe.md deleted file mode 100644 index 419a5569b4..0000000000 --- a/.changeset/quiet-buttons-bathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -chore: remove SvelteKit data attributes from elements.d.ts diff --git a/.changeset/some-teams-pay.md b/.changeset/some-teams-pay.md deleted file mode 100644 index d7ae69566d..0000000000 --- a/.changeset/some-teams-pay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid erroneous async derived expressions for blocks diff --git a/.changeset/violet-pans-know.md b/.changeset/violet-pans-know.md deleted file mode 100644 index 59bf1dcaaf..0000000000 --- a/.changeset/violet-pans-know.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid Cloudflare warnings about not having the "node:crypto" module diff --git a/.changeset/yummy-insects-wonder.md b/.changeset/yummy-insects-wonder.md deleted file mode 100644 index 6e705cd8cf..0000000000 --- a/.changeset/yummy-insects-wonder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reschedule effects inside unskipped branches diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index c7c38302e8..a71effd5dd 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.49.2 + +### Patch Changes + +- chore: remove SvelteKit data attributes from elements.d.ts ([#17613](https://github.com/sveltejs/svelte/pull/17613)) + +- fix: avoid erroneous async derived expressions for blocks ([#17604](https://github.com/sveltejs/svelte/pull/17604)) + +- fix: avoid Cloudflare warnings about not having the "node:crypto" module ([#17612](https://github.com/sveltejs/svelte/pull/17612)) + +- fix: reschedule effects inside unskipped branches ([#17604](https://github.com/sveltejs/svelte/pull/17604)) + ## 5.49.1 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fd4b98679e..48f492783f 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.49.1", + "version": "5.49.2", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 557b1ff156..31538c3fe7 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.49.1'; +export const VERSION = '5.49.2'; export const PUBLIC_VERSION = '5'; From 4f41e816baa007fb6a4e31164da49576e87e342e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 4 Feb 2026 13:07:46 -0500 Subject: [PATCH 25/71] fix: ensure infinite effect loops are cleared after flushing (#17601) * failing effect-loop-infinite test * fix --- .changeset/chatty-mammals-find.md | 5 +++++ .../svelte/src/internal/client/reactivity/batch.js | 3 ++- packages/svelte/tests/runtime-legacy/shared.ts | 2 ++ .../samples/effect-loop-infinite/_config.js | 11 ++++++----- 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 .changeset/chatty-mammals-find.md diff --git a/.changeset/chatty-mammals-find.md b/.changeset/chatty-mammals-find.md new file mode 100644 index 0000000000..373dc0059a --- /dev/null +++ b/.changeset/chatty-mammals-find.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure infinite effect loops are cleared after flushing diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index cef2df4716..9bf93c873f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -647,8 +647,9 @@ function flush_effects() { } } } finally { - is_flushing = false; + queued_root_effects = []; + is_flushing = false; last_scheduled_effect = null; if (DEV) { diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 8c29a6ada2..c5317f822e 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -521,6 +521,8 @@ async function run_test_variant( errors, hydrate: hydrate_fn }); + + flushSync(); } if (config.runtime_error && !unhandled_rejection) { diff --git a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js index 57f60c2b44..44cf5b09e2 100644 --- a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js @@ -11,11 +11,12 @@ export default test({ test({ assert, errors }) { const [button] = document.querySelectorAll('button'); - try { + assert.throws(() => { flushSync(() => button.click()); - } catch (e) { - assert.equal(errors.length, 1); // for whatever reason we can't get the name which should be 'updated at' - assert.ok(/** @type {Error} */ (e).message.startsWith('effect_update_depth_exceeded')); - } + }, /effect_update_depth_exceeded/); + + assert.equal(errors.length, 1); + + assert.doesNotThrow(flushSync); } }); From d7a8e3d1300fbb802b84a6ebf0f3f71ed734a556 Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 4 Feb 2026 21:51:50 +0200 Subject: [PATCH 26/71] fix: emit `each_key_duplicate` error in production (#16724) * fix: emit `each_key_duplicate` error in production * fix: preserve key * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * fix: ensure keys are validated * fix silly test name * fix: cover other case of duplicate keys * emit error on hydration * ensure the error is handled * drop useless tests * unused * finish merge * add lost check back * chore: bump playwright (#17565) * chore: bump playwright * maybe this will help somehow? * err whatever * fix * chore: allow testing in production env 2 (#17590) * Revert "chore: allow testing in production env (#16840)" This reverts commit ffd65e90febc29feaca48e142126a4087fcaca9f. * new approach * fix: handle renderer.run rejections (#17591) * fix: handle renderer run rejections * add test * changeset * simplify * explanatory comment --------- Co-authored-by: Antonio Bennett Co-authored-by: Rich Harris * fix: only create async functions in SSR output when necessary (#17593) * fix: only create async functions in SSR output when necessary * actually... * simplify generated code a bit more * simplify * fix: merge consecutive text nodes during hydration for large text content (#17587) * fix: merge consecutive text nodes during hydration for large text content Fixes #17582 Browsers automatically split text nodes exceeding 65536 characters into multiple consecutive text nodes during HTML parsing. This causes hydration mismatches when Svelte expects a single text node. The fix merges consecutive text nodes during hydration by: - Detecting when the current node is a text node - Finding all consecutive text node siblings - Merging their content into the first text node - Removing the extra text nodes This restores correct hydration behavior for large text content. * add test, fix * fix * fix * changeset --------- Co-authored-by: Miner Co-authored-by: Rich Harris * Version Packages (#17585) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Revert "drop useless tests" This reverts commit 65f77ef8409d6e8c91163f36853d36b05ec01ae8. * update tests * fix test * we don't need to expose this function any more * figured it out... we cant have errors during reconcile * simplify * tweak * unused * revert no-longer-needed change * unused --------- Co-authored-by: Rich Harris Co-authored-by: Antonio Bennett <31296212+Antonio-Bennett@users.noreply.github.com> Co-authored-by: Antonio Bennett Co-authored-by: FORMI <239411042+Richman018@users.noreply.github.com> Co-authored-by: Miner Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/sharp-snakes-poke.md | 5 +++ .../3-transform/client/visitors/EachBlock.js | 4 -- .../src/internal/client/dom/blocks/each.js | 37 +++++++++++++++++++ packages/svelte/src/internal/client/index.js | 2 +- .../svelte/src/internal/client/validate.js | 34 ----------------- .../samples/keyed-each-unique-2/_config.js | 12 ++++++ .../samples/keyed-each-unique-2/main.svelte | 8 ++++ .../samples/keyed-each-unique-3/_config.js | 5 +++ .../samples/keyed-each-unique-3/main.svelte | 7 ++++ .../samples/keyed-each-unique/_config.js | 12 ++++++ .../samples/keyed-each-unique/main.svelte | 8 ++++ 11 files changed, 95 insertions(+), 39 deletions(-) create mode 100644 .changeset/sharp-snakes-poke.md create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte diff --git a/.changeset/sharp-snakes-poke.md b/.changeset/sharp-snakes-poke.md new file mode 100644 index 0000000000..7f7f8aa7b2 --- /dev/null +++ b/.changeset/sharp-snakes-poke.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: emit `each_key_duplicate` error in production diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 1dbc34fdc3..b2724fa90f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -338,10 +338,6 @@ export function EachBlock(node, context) { const statements = [add_svelte_meta(b.call('$.each', ...args), node, 'each')]; - if (dev && node.metadata.keyed) { - statements.unshift(b.stmt(b.call('$.validate_each_keys', thunk, key_function))); - } - if (node.metadata.expression.is_async()) { context.state.init.push( b.stmt( diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6eaeac0f38..25f7cf91eb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -40,6 +40,7 @@ import { get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; +import * as e from '../../errors.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b @@ -290,6 +291,15 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } + if (length > keys.size) { + if (DEV) { + validate_each_keys(array, get_key); + } else { + // in prod, the additional information isn't printed, so don't bother computing it + e.each_key_duplicate('', '', ''); + } + } + // remove excess nodes if (hydrating && length > 0) { set_hydrate_node(skip_nodes()); @@ -676,3 +686,30 @@ function link(state, prev, next) { next.prev = prev; } } + +/** + * @param {Array} array + * @param {(item: any, index: number) => string} key_fn + * @returns {void} + */ +function validate_each_keys(array, key_fn) { + const keys = new Map(); + const length = array.length; + + for (let i = 0; i < length; i++) { + const key = key_fn(array[i], i); + + if (keys.has(key)) { + const a = String(keys.get(key)); + const b = String(i); + + /** @type {string | null} */ + let k = String(key); + if (k.startsWith('[object ')) k = null; + + e.each_key_duplicate(a, b, k); + } + + keys.set(key, i); + } +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 1d9f7dfff7..7fcaf77dc5 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -158,7 +158,7 @@ export { deep_read_state, active_effect } from './runtime.js'; -export { validate_binding, validate_each_keys } from './validate.js'; +export { validate_binding } from './validate.js'; export { raf } from './timing.js'; export { proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 48a44db304..a169225f1e 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,45 +1,11 @@ /** @import { Blocker } from '#client' */ import { dev_current_component_function } from './context.js'; -import { is_array } from '../shared/utils.js'; -import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; import { render_effect } from './reactivity/effects.js'; import * as w from './warnings.js'; import { capture_store_binding } from './reactivity/store.js'; import { run_after_blockers } from './reactivity/async.js'; -/** - * @param {() => any} collection - * @param {(item: any, index: number) => string} key_fn - * @returns {void} - */ -export function validate_each_keys(collection, key_fn) { - render_effect(() => { - const keys = new Map(); - const maybe_array = collection(); - const array = is_array(maybe_array) - ? maybe_array - : maybe_array == null - ? [] - : Array.from(maybe_array); - const length = array.length; - for (let i = 0; i < length; i++) { - const key = key_fn(array[i], i); - if (keys.has(key)) { - const a = String(keys.get(key)); - const b = String(i); - - /** @type {string | null} */ - let k = String(key); - if (k.startsWith('[object ')) k = null; - - e.each_key_duplicate(a, b, k); - } - keys.set(key, i); - } - }); -} - /** * @param {string} binding * @param {Blocker[]} blockers diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte new file mode 100644 index 0000000000..f8ba50d866 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js new file mode 100644 index 0000000000..7e1840200a --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + error: 'each_key_duplicate' +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte new file mode 100644 index 0000000000..a05781bcb9 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte @@ -0,0 +1,7 @@ + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte new file mode 100644 index 0000000000..3d52179372 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each} From f05f946feafd804de3416742e5d2379651087b2e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Feb 2026 09:57:16 -0500 Subject: [PATCH 27/71] fix: add vite-ignore comment inside dynamic crypto import (#17623) --- .changeset/forty-worlds-attack.md | 5 +++++ packages/svelte/src/internal/server/crypto.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/forty-worlds-attack.md diff --git a/.changeset/forty-worlds-attack.md b/.changeset/forty-worlds-attack.md new file mode 100644 index 0000000000..52bb3644c0 --- /dev/null +++ b/.changeset/forty-worlds-attack.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: add vite-ignore comment inside dynamic crypto import diff --git a/packages/svelte/src/internal/server/crypto.js b/packages/svelte/src/internal/server/crypto.js index 9bb6ecdd39..76d66b6d1a 100644 --- a/packages/svelte/src/internal/server/crypto.js +++ b/packages/svelte/src/internal/server/crypto.js @@ -13,7 +13,7 @@ export async function sha256(data) { ? globalThis.crypto : // @ts-ignore - we don't install node types in the prod build // don't use 'node:crypto' because static analysers will think we rely on node when we don't - (await import('node:' + 'crypto')).webcrypto; + (await import(/* @vite-ignore */ 'node:' + 'crypto')).webcrypto; const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data)); From fcbd3e325a62c4a2c83ff51f721fdfe26b7451d9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Feb 2026 09:59:07 -0500 Subject: [PATCH 28/71] feat: allow use of createContext when instantiating components programmatically (#17575) * feat: allow use of createContext when instantiating components programmatically * docs --- .changeset/orange-ants-greet.md | 5 ++++ documentation/docs/06-runtime/02-context.md | 26 +++++++++++++++++++ packages/svelte/src/internal/client/render.js | 12 +++------ .../svelte/src/internal/server/renderer.js | 12 +++------ .../create-context-programmatic/Child.svelte | 7 +++++ .../create-context-programmatic/_config.js | 8 ++++++ .../create-context-programmatic/main.svelte | 20 ++++++++++++++ 7 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 .changeset/orange-ants-greet.md create mode 100644 packages/svelte/tests/runtime-runes/samples/create-context-programmatic/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/create-context-programmatic/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/create-context-programmatic/main.svelte diff --git a/.changeset/orange-ants-greet.md b/.changeset/orange-ants-greet.md new file mode 100644 index 0000000000..6f7c684fee --- /dev/null +++ b/.changeset/orange-ants-greet.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow use of createContext when instantiating components programmatically diff --git a/documentation/docs/06-runtime/02-context.md b/documentation/docs/06-runtime/02-context.md index 0dfb996164..61b203f93e 100644 --- a/documentation/docs/06-runtime/02-context.md +++ b/documentation/docs/06-runtime/02-context.md @@ -97,6 +97,32 @@ import { createContext } from 'svelte'; export const [getUserContext, setUserContext] = createContext(); ``` +When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing: + +```js +import { mount, unmount } from 'svelte'; +import { expect, test } from 'vitest'; +import { setUserContext } from './context'; +import MyComponent from './MyComponent.svelte'; + +test('MyComponent', () => { + function Wrapper(...args) { + setUserContext({ name: 'Bob' }); + return MyComponent(...args); + } + + const component = mount(Wrapper, { + target: document.body + }); + + expect(document.body.innerHTML).toBe('

Hello Bob!

'); + + unmount(component); +}); +``` + +This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render). + ## Replacing global state When you have state shared by many different components, you might be tempted to put it in its own module and just import it wherever it's needed: diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index c76f0b1ce7..c09f5fdd05 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -208,11 +208,9 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro pending: () => {} }, (anchor_node) => { - if (context) { - push({}); - var ctx = /** @type {ComponentContext} */ (component_context); - ctx.c = context; - } + push({}); + var ctx = /** @type {ComponentContext} */ (component_context); + if (context) ctx.c = context; if (events) { // We can't spread the object or else we'd lose the state proxy stuff, if it is one @@ -241,9 +239,7 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro } } - if (context) { - pop(); - } + pop(); } ); diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 65f9bcae6a..49f4c1b7d2 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -617,18 +617,14 @@ export class Renderer { renderer.push(BLOCK_OPEN); - if (options.context) { - push(); - /** @type {SSRContext} */ (ssr_context).c = options.context; - /** @type {SSRContext} */ (ssr_context).r = renderer; - } + push(); + if (options.context) /** @type {SSRContext} */ (ssr_context).c = options.context; + /** @type {SSRContext} */ (ssr_context).r = renderer; // @ts-expect-error component(renderer, options.props ?? {}); - if (options.context) { - pop(); - } + pop(); renderer.push(BLOCK_CLOSE); diff --git a/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/Child.svelte b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/Child.svelte new file mode 100644 index 0000000000..3e39d5043e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/Child.svelte @@ -0,0 +1,7 @@ + + +

{message}

diff --git a/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/_config.js b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/_config.js new file mode 100644 index 0000000000..f4374c8759 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: `
`, + html: `

hello

`, + + test() {} +}); diff --git a/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/main.svelte b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/main.svelte new file mode 100644 index 0000000000..3c42dbf180 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context-programmatic/main.svelte @@ -0,0 +1,20 @@ + + +
{ + mount(Wrapper(Child), { target }); +}}>
From 3970e7a302e29fa09eb3c0351301f260401e32f0 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 5 Feb 2026 13:58:50 -0700 Subject: [PATCH 29/71] chore: fix pkg.pr.new workflow (#17633) --- .github/workflows/pkg.pr.new-comment.yml | 115 ------------- .github/workflows/pkg.pr.new.yml | 205 +++++++++++++++++++++-- 2 files changed, 190 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/pkg.pr.new-comment.yml diff --git a/.github/workflows/pkg.pr.new-comment.yml b/.github/workflows/pkg.pr.new-comment.yml deleted file mode 100644 index 64495cc5c8..0000000000 --- a/.github/workflows/pkg.pr.new-comment.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Update pkg.pr.new comment - -on: - workflow_run: - workflows: ['Publish Any Commit'] - types: - - completed - -permissions: - pull-requests: write - -jobs: - build: - name: 'Update comment' - runs-on: ubuntu-latest - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: output - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} - - - run: ls -R . - - name: 'Post or update comment' - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - - const bot_comment_identifier = ``; - - const body = (number) => `${bot_comment_identifier} - - [Playground](https://svelte.dev/playground?version=pr-${number}) - - \`\`\` - ${output.packages.map((p) => `pnpm add https://pkg.pr.new/${p.name}@${number}`).join('\n')} - \`\`\` - `; - - async function find_bot_comment(issue_number) { - if (!issue_number) return null; - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - }); - return comments.data.find((comment) => - comment.body.includes(bot_comment_identifier) - ); - } - - async function create_or_update_comment(issue_number) { - if (!issue_number) { - console.log('No issue number provided. Cannot post or update comment.'); - return; - } - - const existing_comment = await find_bot_comment(issue_number); - if (existing_comment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing_comment.id, - body: body(issue_number), - }); - } else { - await github.rest.issues.createComment({ - issue_number: issue_number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body(issue_number), - }); - } - } - - async function log_publish_info() { - const svelte_package = output.packages.find(p => p.name === 'svelte'); - const svelte_sha = svelte_package.url.replace(/^.+@([^@]+)$/, '$1'); - console.log('\n' + '='.repeat(50)); - console.log('Publish Information'); - console.log('='.repeat(50)); - console.log('\nPublished Packages:'); - console.log(output.packages.map((p) => `${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.url.replace(/^.+@([^@]+)$/, '$1')}`).join('\n')); - if(svelte_sha){ - console.log('\nPlayground URL:'); - console.log(`\nhttps://svelte.dev/playground?version=commit-${svelte_sha}`) - } - console.log('\n' + '='.repeat(50)); - } - - if (output.event_name === 'pull_request') { - if (output.number) { - await create_or_update_comment(output.number); - } - } else if (output.event_name === 'push') { - const pull_requests = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - head: `${context.repo.owner}:${output.ref.replace('refs/heads/', '')}`, - }); - - if (pull_requests.data.length > 0) { - await create_or_update_comment(pull_requests.data[0].number); - } else { - console.log( - 'No open pull request found for this push. Logging publish information to console:' - ); - await log_publish_info(); - } - } diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 252cbed769..51b7472911 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -1,16 +1,51 @@ -name: Publish Any Commit -on: [push, pull_request] +name: pkg.pr.new +on: + pull_request_target: + types: [opened, synchronize] + push: + branches: [main] permissions: {} jobs: - build: - permissions: {} + # This job determines the environment to use for the build job. It ensures that: + # - For pushes to main, we use the "Publish pkg.pr.new (maintainers)" environment. + # - For PRs from the same repository, we also use the "Publish pkg.pr.new (maintainers)" environment, since these are trusted. + # - For PRs from forks, we use the "Publish pkg.pr.new (external contributors)" environment, which requires manual approval by a maintainer before the build job can run. + # This protects us from running untrusted code while still allowing external contributors to use pkg.pr.new. + resolve-env: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.resolve.outputs.environment }} + steps: + - name: Determine environment + id: resolve + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then + echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" + else + echo "environment=Publish pkg.pr.new (external contributors)" >> "$GITHUB_OUTPUT" + fi + build: + needs: resolve-env runs-on: ubuntu-latest + # This is the line that ensures forks require manual approval before running the build job + environment: ${{ needs.resolve-env.outputs.environment }} + + # No permissions — this job runs user-controlled code + permissions: {} steps: - uses: actions/checkout@v6 + with: + # For pull_request_target, we must explicitly check out the PR head. + # This is safe because the environment gate above has already fired — + # an org member has approved this specific commit for external PRs. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: @@ -24,21 +59,161 @@ jobs: run: pnpm build - run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte' - - name: Add metadata to output + + - name: Upload output + uses: actions/upload-artifact@v4 + with: + name: output + path: ./output.json + + # Sanitizes the untrusted output from the build job before it's consumed by + # jobs with elevated permissions. This ensures that only known package names + # and valid SHA prefixes make it through. + sanitize: + needs: build + runs-on: ubuntu-latest + + permissions: {} + + steps: + - name: Download artifact + uses: actions/download-artifact@v7 + with: + name: output + + - name: Sanitize output + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const raw = JSON.parse(fs.readFileSync('output.json', 'utf8')); + + const ALLOWED_PACKAGES = new Set(['svelte']); + const SHA_PATTERN = /^[0-9a-f]{6}$/; + + const packages = (raw.packages || []) + .filter(p => { + if (!ALLOWED_PACKAGES.has(p.name)) { + console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`); + return false; + } + const sha = p.url?.replace(/^.+@([^@]+)$/, '$1'); + if (!sha || !SHA_PATTERN.test(sha)) { + console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`); + return false; + } + return true; + }) + .map(p => ({ + name: p.name, + sha: p.url.replace(/^.+@([^@]+)$/, '$1'), + })); + + fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8'); + + - name: Upload sanitized output + uses: actions/upload-artifact@v4 + with: + name: sanitized-output + path: ./sanitized-output.json + + comment: + needs: sanitize + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + + permissions: + pull-requests: write + + steps: + - name: Download sanitized artifact + uses: actions/download-artifact@v7 + with: + name: sanitized-output + + - name: Post or update comment uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - output.number = context.issue.number; - output.event_name = context.eventName; - output.ref = context.ref; - fs.writeFileSync('output.json', JSON.stringify(output), 'utf8'); - - name: Upload output - uses: actions/upload-artifact@v6 + const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); + + if (packages.length === 0) { + console.log('No valid packages found. Skipping comment.'); + return; + } + + // Issue number from trusted event context, never from the artifact + const issue_number = context.issue.number; + + const bot_comment_identifier = ``; + + const body = `${bot_comment_identifier} + + [Playground](https://svelte.dev/playground?version=pr-${issue_number}) + + \`\`\` + ${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')} + \`\`\` + `; + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + }); + const existing = comments.data.find(c => c.body.includes(bot_comment_identifier)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }); + } + + log: + needs: sanitize + if: github.event_name == 'push' + runs-on: ubuntu-latest + + permissions: {} + + steps: + - name: Download sanitized artifact + uses: actions/download-artifact@v7 with: - name: output - path: ./output.json + name: sanitized-output + + - name: Log publish info + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); + + if (packages.length === 0) { + console.log('No valid packages found.'); + return; + } - - run: ls -R . + console.log('\n' + '='.repeat(50)); + console.log('Publish Information'); + console.log('='.repeat(50)); + for (const p of packages) { + console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`); + } + const svelte = packages.find(p => p.name === 'svelte'); + if (svelte) { + console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`); + } + console.log('='.repeat(50)); From 660c4c12b1e1c50d067dfd800465bea21d29651f Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 5 Feb 2026 14:04:56 -0700 Subject: [PATCH 30/71] chore: off-by-one in workflow (#17634) --- .github/workflows/pkg.pr.new.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 51b7472911..0f0c433361 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -89,7 +89,7 @@ jobs: const raw = JSON.parse(fs.readFileSync('output.json', 'utf8')); const ALLOWED_PACKAGES = new Set(['svelte']); - const SHA_PATTERN = /^[0-9a-f]{6}$/; + const SHA_PATTERN = /^[0-9a-f]{7}$/; const packages = (raw.packages || []) .filter(p => { From a75866f34d5b64e0c40b2888a5f89a69ec1151f9 Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:03:40 +0100 Subject: [PATCH 31/71] fix: detect store in each block expression regardless of AST shape (#17636) The store invalidation detection in each blocks only checked for Identifier and MemberExpression AST node types. This caused bind: on iteration variables to silently fail when the expression used logical operators (e.g. `{#each $store.items ?? [] as item}`). Use expression metadata dependencies instead of AST type checking to find store_sub bindings, which correctly handles all expression shapes. Fixes #14625 --- .changeset/fix-each-bind-store-logical.md | 5 +++++ .../3-transform/client/visitors/EachBlock.js | 12 ++++------ .../store-each-binding-logical/_config.js | 22 +++++++++++++++++++ .../store-each-binding-logical/main.svelte | 13 +++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-each-bind-store-logical.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/main.svelte diff --git a/.changeset/fix-each-bind-store-logical.md b/.changeset/fix-each-bind-store-logical.md new file mode 100644 index 0000000000..1327015124 --- /dev/null +++ b/.changeset/fix-each-bind-store-logical.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: detect store in each block expression regardless of AST shape diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index b2724fa90f..a1371b516a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -101,15 +101,11 @@ export function EachBlock(node, context) { } // If the array is a store expression, we need to invalidate it when the array is changed. - // This doesn't catch all cases, but all the ones that Svelte 4 catches, too. let store_to_invalidate = ''; - if (node.expression.type === 'Identifier' || node.expression.type === 'MemberExpression') { - const id = object(node.expression); - if (id) { - const binding = context.state.scope.get(id.name); - if (binding?.kind === 'store_sub') { - store_to_invalidate = id.name; - } + for (const binding of node.metadata.expression.dependencies) { + if (binding.kind === 'store_sub') { + store_to_invalidate = binding.node.name; + break; } } diff --git a/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/_config.js b/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/_config.js new file mode 100644 index 0000000000..1134b20d0c --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/_config.js @@ -0,0 +1,22 @@ +import { flushSync } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + test({ assert, target, window }) { + const input = target.querySelector('input'); + ok(input); + + const event = new window.Event('input'); + input.value = 'changed'; + input.dispatchEvent(event); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + +

changed

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/main.svelte b/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/main.svelte new file mode 100644 index 0000000000..77731ea5a4 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/store-each-binding-logical/main.svelte @@ -0,0 +1,13 @@ + + +{#each $items ?? [] as item} + +{/each} + +

{$items[0].text}

From f5304ec8c944b1893e8150449d26cf07af910d7b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Feb 2026 04:11:26 -0500 Subject: [PATCH 32/71] chore: simplify SSR code (#17639) creates a few abstractions in the compiler code around common "if-else" scenarios, as well as introducing a new runtime render helper --------- Co-authored-by: Simon Holthausen Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .../3-transform/client/visitors/Fragment.js | 40 +++--- .../3-transform/server/transform-server.js | 11 +- .../phases/3-transform/server/types.d.ts | 3 +- .../3-transform/server/visitors/AwaitBlock.js | 15 ++- .../3-transform/server/visitors/EachBlock.js | 30 ++--- .../3-transform/server/visitors/Fragment.js | 2 +- .../3-transform/server/visitors/HtmlTag.js | 25 ++-- .../3-transform/server/visitors/IfBlock.js | 21 ++-- .../server/visitors/RegularElement.js | 55 ++------ .../3-transform/server/visitors/RenderTag.js | 14 +-- .../server/visitors/SlotElement.js | 11 +- .../server/visitors/SvelteElement.js | 53 +++----- .../server/visitors/shared/component.js | 41 ++---- .../server/visitors/shared/utils.js | 118 +++++++++--------- .../svelte/src/internal/server/renderer.js | 9 ++ .../_expected/server/index.svelte.js | 6 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 6 +- 21 files changed, 184 insertions(+), 284 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 79a443967c..f463111c4d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -120,34 +120,32 @@ export function Fragment(node, context) { state.init.unshift(b.var(id, b.call('$.text'))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); + } else if (is_standalone) { + // no need to create a template, we can just use the existing block's anchor + process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state }); } else { - if (is_standalone) { - // no need to create a template, we can just use the existing block's anchor - process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state }); - } else { - /** @type {(is_text: boolean) => Expression} */ - const expression = (is_text) => b.call('$.first_child', id, is_text && b.true); - - process_children(trimmed, expression, false, { ...context, state }); + /** @type {(is_text: boolean) => Expression} */ + const expression = (is_text) => b.call('$.first_child', id, is_text && b.true); - let flags = TEMPLATE_FRAGMENT; + process_children(trimmed, expression, false, { ...context, state }); - if (state.template.needs_import_node) { - flags |= TEMPLATE_USE_IMPORT_NODE; - } + let flags = TEMPLATE_FRAGMENT; - if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') { - // special case — we can use `$.comment` instead of creating a unique template - state.init.unshift(b.var(id, b.call('$.comment'))); - } else { - const template = transform_template(state, namespace, flags); - state.hoisted.push(b.var(template_name, template)); + if (state.template.needs_import_node) { + flags |= TEMPLATE_USE_IMPORT_NODE; + } - state.init.unshift(b.var(id, b.call(template_name))); - } + if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') { + // special case — we can use `$.comment` instead of creating a unique template + state.init.unshift(b.var(id, b.call('$.comment'))); + } else { + const template = transform_template(state, namespace, flags); + state.hoisted.push(b.var(template_name, template)); - close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); + state.init.unshift(b.var(id, b.call(template_name))); } + + close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 0e2f68d0f5..b9f1441bff 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -41,7 +41,6 @@ import { TitleElement } from './visitors/TitleElement.js'; import { UpdateExpression } from './visitors/UpdateExpression.js'; import { VariableDeclaration } from './visitors/VariableDeclaration.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js'; -import { call_component_renderer } from './visitors/shared/utils.js'; /** @type {Visitors} */ const global_visitors = { @@ -105,7 +104,7 @@ export function server_component(analysis, options) { namespace: options.namespace, preserve_whitespace: options.preserveWhitespace, state_fields: new Map(), - skip_hydration_boundaries: false, + is_standalone: false, is_instance: false }; @@ -260,7 +259,13 @@ export function server_component(analysis, options) { if (should_inject_context) { component_block = b.block([ - call_component_renderer(component_block, dev && b.id(component_name)) + b.stmt( + b.call( + '$$renderer.component', + b.arrow([b.id('$$renderer')], component_block, false), + dev && b.id(component_name) + ) + ) ]); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts index e7a72fb8ad..4912728a1e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts @@ -26,7 +26,8 @@ export interface ComponentServerTransformState extends ServerTransformState { readonly template: Array; readonly namespace: Namespace; readonly preserve_whitespace: boolean; - readonly skip_hydration_boundaries: boolean; + /** True if the current node is a) a component or render tag and b) the sole child of a block */ + readonly is_standalone: boolean; /** Transformed async `{@const }` declarations (if any) and those coming after them */ async_consts?: { id: Identifier; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js index b8d2e42144..84c2a81612 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, create_async_block } from './shared/utils.js'; +import { block_close, create_child_block } from './shared/utils.js'; /** * @param {AST.AwaitBlock} node @@ -25,13 +25,12 @@ export function AwaitBlock(node, context) { ) ); - if (node.metadata.expression.is_async()) { - statement = create_async_block( - b.block([statement]), + context.state.template.push( + ...create_child_block( + [statement], node.metadata.expression.blockers(), node.metadata.expression.has_await - ); - } - - context.state.template.push(statement, block_close); + ), + block_close + ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js index 3c0a8c1676..cb5a61d52f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, block_open, block_open_else, create_async_block } from './shared/utils.js'; +import { block_close, block_open, block_open_else, create_child_block } from './shared/utils.js'; /** * @param {AST.EachBlock} node @@ -18,8 +18,8 @@ export function EachBlock(node, context) { const array_id = state.scope.root.unique('each_array'); - /** @type {Statement} */ - let block = b.block([b.const(array_id, b.call('$.ensure_array_like', collection))]); + /** @type {Statement[]} */ + let statements = [b.const(array_id, b.call('$.ensure_array_like', collection))]; /** @type {Statement[]} */ const each = []; @@ -53,7 +53,7 @@ export function EachBlock(node, context) { fallback.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open_else))); - block.body.push( + statements.push( b.if( b.binary('!==', b.member(array_id, 'length'), b.literal(0)), b.block([open, for_loop]), @@ -62,19 +62,15 @@ export function EachBlock(node, context) { ); } else { state.template.push(block_open); - block.body.push(for_loop); + statements.push(for_loop); } - if (node.metadata.expression.is_async()) { - state.template.push( - create_async_block( - block, - node.metadata.expression.blockers(), - node.metadata.expression.has_await - ), - block_close - ); - } else { - state.template.push(...block.body, block_close); - } + state.template.push( + ...create_child_block( + statements, + node.metadata.expression.blockers(), + node.metadata.expression.has_await + ), + block_close + ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js index ef5bd985ae..a6ff33c7ab 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js @@ -28,7 +28,7 @@ export function Fragment(node, context) { init: [], template: [], namespace, - skip_hydration_boundaries: is_standalone, + is_standalone, async_consts: undefined }; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/HtmlTag.js index 3f423fa60d..ee790c3e7c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/HtmlTag.js @@ -2,25 +2,24 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, block_open, create_push } from './shared/utils.js'; +import { create_child_block } from './shared/utils.js'; /** * @param {AST.HtmlTag} node * @param {ComponentContext} context */ export function HtmlTag(node, context) { - const expression = /** @type {Expression} */ (context.visit(node.expression)); - const call = b.call('$.html', expression); + const expression = b.call('$.html', /** @type {Expression} */ (context.visit(node.expression))); - const has_await = node.metadata.expression.has_await; - - if (has_await) { - context.state.template.push(block_open); - } - - context.state.template.push(create_push(call, node.metadata.expression, true)); - - if (has_await) { - context.state.template.push(block_close); + if (node.metadata.expression.is_async()) { + context.state.template.push( + ...create_child_block( + [b.stmt(b.call('$$renderer.push', expression))], + node.metadata.expression.blockers(), + node.metadata.expression.has_await + ) + ); + } else { + context.state.template.push(expression); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js index e8418343be..06b1b1e966 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, block_open, block_open_else, create_async_block } from './shared/utils.js'; +import { block_close, block_open, block_open_else, create_child_block } from './shared/utils.js'; /** * @param {AST.IfBlock} node @@ -23,17 +23,12 @@ export function IfBlock(node, context) { /** @type {Statement} */ let statement = b.if(test, consequent, alternate); - const is_async = node.metadata.expression.is_async(); - - const has_await = node.metadata.expression.has_await; - - if (is_async || has_await) { - statement = create_async_block( - b.block([statement]), + context.state.template.push( + ...create_child_block( + [statement], node.metadata.expression.blockers(), - !!has_await - ); - } - - context.state.template.push(statement, block_close); + node.metadata.expression.has_await + ), + block_close + ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index 5b905752f4..1c6bb0a198 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -1,5 +1,4 @@ /** @import { Expression } from 'estree' */ -/** @import { Location } from 'locate-character' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */ /** @import { Scope } from '../../../scope.js' */ @@ -8,13 +7,7 @@ import { dev, locator } from '../../../../state.js'; import * as b from '#compiler/builders'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; import { build_element_attributes, prepare_element_spread_object } from './shared/element.js'; -import { - process_children, - build_template, - create_child_block, - PromiseOptimiser, - create_async_block -} from './shared/utils.js'; +import { process_children, build_template, PromiseOptimiser } from './shared/utils.js'; import { is_customizable_select_element } from '../../../nodes.js'; /** @@ -66,17 +59,9 @@ export function RegularElement(node, context) { b.literal(``) ); - // TODO this is a real edge case, would be good to DRY this out - if (optimiser.expressions.length > 0) { - context.state.template.push( - create_child_block( - b.block([optimiser.apply(), ...state.init, ...build_template(state.template)]) - ) - ); - } else { - context.state.init.push(...state.init); - context.state.template.push(...state.template); - } + context.state.template.push( + ...optimiser.render([...state.init, ...build_template(state.template)]) + ); return; } @@ -130,13 +115,7 @@ export function RegularElement(node, context) { const statement = b.stmt(b.call('$$renderer.select', attributes, fn, ...rest)); - if (optimiser.expressions.length > 0) { - context.state.template.push( - create_child_block(b.block([optimiser.apply(), ...state.init, statement])) - ); - } else { - context.state.template.push(...state.init, statement); - } + context.state.template.push(...optimiser.render([...state.init, statement])); return; } @@ -183,13 +162,7 @@ export function RegularElement(node, context) { const statement = b.stmt(b.call('$$renderer.option', attributes, body, ...rest)); - if (optimiser.expressions.length > 0) { - context.state.template.push( - create_child_block(b.block([optimiser.apply(), ...state.init, statement])) - ); - } else { - context.state.template.push(...state.init, statement); - } + context.state.template.push(...optimiser.render([...state.init, statement])); return; } @@ -235,19 +208,9 @@ export function RegularElement(node, context) { } if (optimiser.is_async()) { - let statements = [...state.init, ...build_template(state.template)]; - - if (optimiser.has_await) { - statements = [create_child_block(b.block([optimiser.apply(), ...statements]))]; - } - - const blockers = optimiser.blockers(); - - if (blockers.elements.length > 0) { - statements = [create_async_block(b.block(statements), blockers, false, false)]; - } - - context.state.template.push(...statements); + context.state.template.push( + ...optimiser.render([...state.init, ...build_template(state.template)]) + ); } else { context.state.init.push(...state.init); context.state.template.push(...state.template); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RenderTag.js index 6d7cef0d95..fe20e85e1b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RenderTag.js @@ -3,7 +3,7 @@ /** @import { ComponentContext } from '../types.js' */ import { unwrap_optional } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; -import { create_async_block, empty_comment, PromiseOptimiser } from './shared/utils.js'; +import { empty_comment, PromiseOptimiser } from './shared/utils.js'; /** * @param {AST.RenderTag} node @@ -35,17 +35,9 @@ export function RenderTag(node, context) { ) ); - if (optimiser.is_async()) { - statement = create_async_block( - b.block([optimiser.apply(), statement]), - optimiser.blockers(), - optimiser.has_await - ); - } - - context.state.template.push(statement); + context.state.template.push(...optimiser.render_block([statement])); - if (!context.state.skip_hydration_boundaries) { + if (!context.state.is_standalone) { context.state.template.push(empty_comment); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SlotElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SlotElement.js index d0f8e25d02..3cebdf4541 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SlotElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SlotElement.js @@ -5,7 +5,6 @@ import * as b from '#compiler/builders'; import { build_attribute_value, PromiseOptimiser, - create_async_block, block_open, block_close } from './shared/utils.js'; @@ -65,13 +64,5 @@ export function SlotElement(node, context) { fallback ); - const statement = optimiser.is_async() - ? create_async_block( - b.block([optimiser.apply(), b.stmt(slot)]), - optimiser.blockers(), - optimiser.has_await - ) - : b.stmt(slot); - - context.state.template.push(block_open, statement, block_close); + context.state.template.push(block_open, ...optimiser.render_block([b.stmt(slot)]), block_close); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js index 75ba323e20..398ecc68a2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteElement.js @@ -1,4 +1,3 @@ -/** @import { Location } from 'locate-character' */ /** @import { BlockStatement, Expression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ @@ -6,12 +5,7 @@ import { dev, locator } from '../../../../state.js'; import * as b from '#compiler/builders'; import { determine_namespace_for_children } from '../../utils.js'; import { build_element_attributes } from './shared/element.js'; -import { - build_template, - create_async_block, - create_child_block, - PromiseOptimiser -} from './shared/utils.js'; +import { build_template, create_child_block, PromiseOptimiser } from './shared/utils.js'; /** * @param {AST.SvelteElement} node @@ -67,36 +61,29 @@ export function SvelteElement(node, context) { const attributes = b.block([...state.init, ...build_template(state.template)]); const children = /** @type {BlockStatement} */ (context.visit(node.fragment, state)); - /** @type {Statement} */ - let statement = b.stmt( - b.call( - '$.element', - b.id('$$renderer'), - tag, - attributes.body.length > 0 && b.thunk(attributes), - children.body.length > 0 && b.thunk(children) - ) + statements.push( + ...optimiser.render([ + b.stmt( + b.call( + '$.element', + b.id('$$renderer'), + tag, + attributes.body.length > 0 && b.thunk(attributes), + children.body.length > 0 && b.thunk(children) + ) + ) + ]) ); - if (optimiser.expressions.length > 0) { - statement = create_child_block(b.block([optimiser.apply(), statement])); - } - - statements.push(statement); - if (dev) { statements.push(b.stmt(b.call('$.pop_element'))); } - if (node.metadata.expression.is_async()) { - statements = [ - create_async_block( - b.block(statements), - node.metadata.expression.blockers(), - node.metadata.expression.has_await - ) - ]; - } - - context.state.template.push(...statements); + context.state.template.push( + ...create_child_block( + statements, + node.metadata.expression.blockers(), + node.metadata.expression.has_await + ) + ); } 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 cfd9a27d7b..fe49a67b28 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,12 +1,7 @@ /** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../../types.js' */ -import { - empty_comment, - build_attribute_value, - create_async_block, - PromiseOptimiser -} from './utils.js'; +import { empty_comment, build_attribute_value, PromiseOptimiser } from './utils.js'; import * as b from '#compiler/builders'; import { is_element_node } from '../../../../nodes.js'; import { dev } from '../../../../../state.js'; @@ -325,32 +320,16 @@ export function build_inline_component(node, expression, context) { optimiser.check_blockers(node.metadata.expression); } - const is_async = optimiser.is_async(); - - if (is_async) { - statement = create_async_block( - b.block([ - optimiser.apply(), - dynamic && custom_css_props.length === 0 - ? b.stmt(b.call('$$renderer.push', empty_comment)) - : b.empty, - statement - ]), - optimiser.blockers(), - optimiser.has_await - ); - } else if (dynamic && custom_css_props.length === 0) { - context.state.template.push(empty_comment); - } - - context.state.template.push(statement); + context.state.template.push( + ...optimiser.render_block([ + dynamic && custom_css_props.length === 0 + ? b.stmt(b.call('$$renderer.push', empty_comment)) + : b.empty, + statement + ]) + ); - if ( - !is_async && - !context.state.skip_hydration_boundaries && - custom_css_props.length === 0 && - optimiser.expressions.length === 0 - ) { + if (!optimiser.is_async() && !context.state.is_standalone && custom_css_props.length === 0) { context.state.template.push(empty_comment); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index b02a935ed2..ee14a4d135 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -81,7 +81,19 @@ export function process_children(nodes, { visit, state }) { flush(); const expression = /** @type {Expression} */ (visit(node.expression)); - state.template.push(create_push(b.call('$.escape', expression), node.metadata.expression)); + + let call = b.call( + '$$renderer.push', + b.thunk(b.call('$.escape', expression), node.metadata.expression.has_await) + ); + + const blockers = node.metadata.expression.blockers(); + + if (blockers.elements.length > 0) { + call = b.call('$$renderer.async', blockers, b.arrow([b.id('$$renderer')], call)); + } + + state.template.push(b.stmt(call)); } else if (node.type === 'Text' || node.type === 'Comment' || node.type === 'ExpressionTag') { sequence.push(node); } else { @@ -262,72 +274,20 @@ export function build_getter(node, state) { } /** - * Creates a `$$renderer.child(...)` expression statement - * @param {BlockStatement | Expression} body - * @returns {Statement} - */ -export function create_child_block(body) { - return b.stmt(b.call('$$renderer.child', b.arrow([b.id('$$renderer')], body, true))); -} - -/** - * Creates a `$$renderer.async(...)` expression statement - * @param {BlockStatement | Expression} body + * @param {Statement[]} statements * @param {ArrayExpression} blockers * @param {boolean} has_await - * @param {boolean} needs_hydration_markers */ -export function create_async_block( - body, - blockers = b.array([]), - has_await = true, - needs_hydration_markers = true -) { - return b.stmt( - b.call( - needs_hydration_markers ? '$$renderer.async_block' : '$$renderer.async', - blockers, - b.arrow([b.id('$$renderer')], body, has_await) - ) - ); -} - -/** - * @param {Expression} expression - * @param {ExpressionMetadata} metadata - * @param {boolean} needs_hydration_markers - * @returns {Expression | Statement} - */ -export function create_push(expression, metadata, needs_hydration_markers = false) { - if (metadata.is_async()) { - let statement = b.stmt(b.call('$$renderer.push', b.thunk(expression, metadata.has_await))); - - const blockers = metadata.blockers(); - - if (blockers.elements.length > 0) { - statement = create_async_block( - b.block([statement]), - blockers, - false, - needs_hydration_markers - ); - } - - return statement; +export function create_child_block(statements, blockers, has_await) { + if (blockers.elements.length === 0 && !has_await) { + return statements; } - return expression; -} + const fn = b.arrow([b.id('$$renderer')], b.block(statements), has_await); -/** - * @param {BlockStatement | Expression} body - * @param {Identifier | false} component_fn_id - * @returns {Statement} - */ -export function call_component_renderer(body, component_fn_id) { - return b.stmt( - b.call('$$renderer.component', b.arrow([b.id('$$renderer')], body, false), component_fn_id) - ); + return blockers.elements.length > 0 + ? [b.stmt(b.call('$$renderer.async_block', blockers, fn))] + : [b.stmt(b.call('$$renderer.child_block', fn))]; } /** @@ -373,7 +333,7 @@ export class PromiseOptimiser { } } - apply() { + #apply() { if (this.expressions.length === 0) { return b.empty; } @@ -403,4 +363,38 @@ export class PromiseOptimiser { is_async() { return this.expressions.length > 0 || this.#blockers.size > 0; } + + /** + * @param {Statement[]} statements + * @returns {Statement[]} + */ + render(statements) { + if (!this.is_async()) { + return statements; + } + + const fn = b.arrow( + [b.id('$$renderer')], + b.block([this.#apply(), ...statements]), + this.has_await + ); + + const blockers = this.blockers(); + + return blockers.elements.length > 0 + ? [b.stmt(b.call('$$renderer.async', blockers, fn))] + : [b.stmt(b.call('$$renderer.child', fn))]; + } + + /** + * @param {Statement[]} statements + * @returns {Statement[]} + */ + render_block(statements) { + if (!this.is_async()) { + return statements; + } + + return create_child_block([this.#apply(), ...statements], this.blockers(), this.has_await); + } } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 49f4c1b7d2..62196350bf 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -171,6 +171,15 @@ export class Renderer { return promises; } + /** + * @param {(renderer: Renderer) => MaybePromise} fn + */ + child_block(fn) { + this.#out.push(BLOCK_OPEN); + this.child(fn); + this.#out.push(BLOCK_CLOSE); + } + /** * Create a child renderer. The child renderer inherits the state from the parent, * but has its own content. diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js index a726b903bc..03bbc5ba88 100644 --- a/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/server/index.svelte.js @@ -19,11 +19,7 @@ export default function Async_const($$renderer) { ]); $$renderer.push(`

`); - - $$renderer.async([promises[1]], ($$renderer) => { - $$renderer.push(() => $.escape(b)); - }); - + $$renderer.async([promises[1]], ($$renderer) => $$renderer.push(() => $.escape(b))); $$renderer.push(`

`); } else { $$renderer.push(''); diff --git a/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/server/index.svelte.js index 7249fd6e4f..b331875d04 100644 --- a/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/server/index.svelte.js @@ -2,7 +2,7 @@ import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/server'; export default function Async_each_fallback_hoisting($$renderer) { - $$renderer.async_block([], async ($$renderer) => { + $$renderer.child_block(async ($$renderer) => { const each_array = $.ensure_array_like((await $.save(Promise.resolve([])))()); if (each_array.length !== 0) { diff --git a/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/server/index.svelte.js index 43fe9414eb..86948b4a3a 100644 --- a/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/server/index.svelte.js @@ -8,7 +8,7 @@ export default function Async_each_hoisting($$renderer) { $$renderer.push(``); - $$renderer.async_block([], async ($$renderer) => { + $$renderer.child_block(async ($$renderer) => { const each_array = $.ensure_array_like((await $.save(Promise.resolve([first, second, third])))()); for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) { diff --git a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js index 1e7330429a..c69c038973 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/server/index.svelte.js @@ -2,7 +2,7 @@ import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/server'; export default function Async_if_alternate_hoisting($$renderer) { - $$renderer.async_block([], async ($$renderer) => { + $$renderer.child_block(async ($$renderer) => { if ((await $.save(Promise.resolve(false)))()) { $$renderer.push(''); $$renderer.push(async () => $.escape(await Promise.reject('no no no'))); diff --git a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js index 1ca24cf81a..1355ba34f0 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/server/index.svelte.js @@ -2,7 +2,7 @@ import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/server'; export default function Async_if_hoisting($$renderer) { - $$renderer.async_block([], async ($$renderer) => { + $$renderer.child_block(async ($$renderer) => { if ((await $.save(Promise.resolve(true)))()) { $$renderer.push(''); $$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes'))); diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js index eb99a2fe1d..cff5f2d569 100644 --- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js @@ -6,10 +6,6 @@ export default function Async_top_level_inspect_server($$renderer) { var $$promises = $$renderer.run([async () => data = await Promise.resolve(42),,]); $$renderer.push(`

`); - - $$renderer.async([$$promises[1]], ($$renderer) => { - $$renderer.push(() => $.escape(data)); - }); - + $$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(data))); $$renderer.push(`

`); } \ No newline at end of file From bc449758695ec0b5cd34b4cdfa7aaf1ccbc5105b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Feb 2026 04:19:14 -0500 Subject: [PATCH 33/71] fix: properly hydrate already-resolved async blocks (alternative) (#17641) This is basically #17611, minus #17640, plus #17639. We need to add the $.next() call after render tags as well as components; rather than duplicating the logic, we can use is_standalone to determine when this is necessary (since this is what prevents $.append(...) from being used). Fixes #17261 Fixes #17608 --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/poor-students-nail.md | 5 ++++ .../3-transform/client/transform-client.js | 1 + .../phases/3-transform/client/types.d.ts | 3 +++ .../3-transform/client/visitors/Fragment.js | 5 +++- .../3-transform/client/visitors/RenderTag.js | 4 ++++ .../client/visitors/shared/component.js | 24 ++++++++++++------- .../server/visitors/shared/component.js | 8 ++++++- .../Inner.svelte | 4 ++++ .../Outer.svelte | 4 ++++ .../Trigger.svelte | 7 ++++++ .../_config.js | 10 ++++++++ .../main.svelte | 16 +++++++++++++ .../Component.svelte | 5 ++++ .../async-each-item-duplication/_config.js | 14 +++++++++++ .../async-each-item-duplication/main.svelte | 10 ++++++++ 15 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 .changeset/poor-students-nail.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Inner.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Outer.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Trigger.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte diff --git a/.changeset/poor-students-nail.md b/.changeset/poor-students-nail.md new file mode 100644 index 0000000000..cee650c002 --- /dev/null +++ b/.changeset/poor-students-nail.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: properly hydrate already-resolved async blocks diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index d5ce3caaa9..b50a73b8b6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -166,6 +166,7 @@ export function client_component(analysis, options) { in_constructor: false, instance_level_snippets: [], module_level_snippets: [], + is_standalone: false, // these are set inside the `Fragment` visitor, and cannot be used until then init: /** @type {any} */ (null), diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 4438ec015b..287bf24ac6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -83,6 +83,9 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly instance_level_snippets: VariableDeclaration[]; /** Snippets hoisted to the module */ readonly module_level_snippets: VariableDeclaration[]; + + /** True if the current node is a) a component or render tag and b) the sole child of a block */ + readonly is_standalone: boolean; } export type Context = import('zimmerframe').Context; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index f463111c4d..00b0cfaa2e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -122,7 +122,10 @@ export function Fragment(node, context) { close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } else if (is_standalone) { // no need to create a template, we can just use the existing block's anchor - process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state }); + process_children(trimmed, () => b.id('$$anchor'), false, { + ...context, + state: { ...state, is_standalone } + }); } else { /** @type {(is_text: boolean) => Expression} */ const expression = (is_text) => b.call('$.first_child', id, is_text && b.true); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js index d14336bb7e..5d39cf2216 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js @@ -85,6 +85,10 @@ export function RenderTag(node, context) { ) ) ); + + if (context.state.is_standalone) { + context.state.init.push(b.stmt(b.call('$.next'))); + } } else { context.state.init.push(statements.length === 1 ? statements[0] : b.block(statements)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index bb72794af8..1d6d3413bf 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -461,7 +461,7 @@ export function build_component(node, component_name, loc, context) { memoizer.check_blockers(node.metadata.expression); } - const statements = [...snippet_declarations, ...memoizer.deriveds(context.state.analysis.runes)]; + let statements = [...snippet_declarations, ...memoizer.deriveds(context.state.analysis.runes)]; if (is_component_dynamic) { const prev = fn; @@ -515,15 +515,21 @@ export function build_component(node, component_name, loc, context) { const blockers = memoizer.blockers(); if (async_values || blockers) { - return b.stmt( - b.call( - '$.async', - anchor, - blockers, - async_values, - b.arrow([b.id('$$anchor'), ...memoizer.async_ids()], b.block(statements)) + statements = [ + b.stmt( + b.call( + '$.async', + anchor, + blockers, + async_values, + b.arrow([b.id('$$anchor'), ...memoizer.async_ids()], b.block(statements)) + ) ) - ); + ]; + + if (context.state.is_standalone) { + statements.push(b.stmt(b.call('$.next'))); + } } return statements.length > 1 ? b.block(statements) : statements[0]; 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 fe49a67b28..6a2c6eb0be 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 @@ -101,10 +101,16 @@ export function build_inline_component(node, expression, context) { } push_prop(b.prop('init', b.key(attribute.name), value)); - } else if (attribute.type === 'BindDirective' && attribute.name !== 'this') { + } else if (attribute.type === 'BindDirective') { // Bindings are a bit special: we don't want to add them to (async) deriveds but we need to check if they have blockers optimiser.check_blockers(attribute.metadata.expression); + if (attribute.name === 'this') { + // bind:this is client-only, but we still need to check for blockers to ensure + // the server generates matching hydration markers if the client wraps in $.async + continue; + } + if (attribute.expression.type === 'SequenceExpression') { const [get, set] = /** @type {SequenceExpression} */ (context.visit(attribute.expression)) .expressions; diff --git a/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Inner.svelte new file mode 100644 index 0000000000..99f885189b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Inner.svelte @@ -0,0 +1,4 @@ + +
{@render children?.()}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Outer.svelte b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Outer.svelte new file mode 100644 index 0000000000..99f885189b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Outer.svelte @@ -0,0 +1,4 @@ + +
{@render children?.()}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Trigger.svelte b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Trigger.svelte new file mode 100644 index 0000000000..fc434d748e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/Trigger.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/_config.js new file mode 100644 index 0000000000..d77ba45ae4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '
foo
'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/main.svelte new file mode 100644 index 0000000000..6cfc73ca25 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attach-hydration-mismatch/main.svelte @@ -0,0 +1,16 @@ + + + + + foo + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/Component.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/Component.svelte new file mode 100644 index 0000000000..9f4e638629 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/Component.svelte @@ -0,0 +1,5 @@ + + +

{message}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/_config.js new file mode 100644 index 0000000000..2e20f83f7f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/_config.js @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + + ssrHtml: `

item 1

item 2

item 3

`, + html: `

item 1

item 2

item 3

`, + + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

item 1

item 2

item 3

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte new file mode 100644 index 0000000000..ae54b63414 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte @@ -0,0 +1,10 @@ + + +{#each messages as message} + +{/each} From 6e5f2b157a02df4dd1f4e2ad3275655bc52cd023 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Feb 2026 10:54:15 -0500 Subject: [PATCH 34/71] fix: exit resolved async blocks on correct node when hydrating (#17640) * fix: exit resolved async blocks on correct node when hydrating * expand test + fix * tweak, add note to self --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen --- .changeset/tiny-owls-pay.md | 5 +++++ .../src/internal/client/dom/blocks/async.js | 15 ++++++++++++++- .../samples/async-if-hydration/Child.svelte | 5 +++++ .../samples/async-if-hydration/_config.js | 11 +++++++++++ .../samples/async-if-hydration/main.svelte | 18 ++++++++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/tiny-owls-pay.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-hydration/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-hydration/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-hydration/main.svelte diff --git a/.changeset/tiny-owls-pay.md b/.changeset/tiny-owls-pay.md new file mode 100644 index 0000000000..ac25500258 --- /dev/null +++ b/.changeset/tiny-owls-pay.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: exit resolved async blocks on correct node when hydrating diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 0e3ab33dda..e8c9cf0643 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -20,13 +20,27 @@ import { get_boundary } from './boundary.js'; */ export function async(node, blockers = [], expressions = [], fn) { var was_hydrating = hydrating; + var end = null; if (was_hydrating) { hydrate_next(); + end = skip_nodes(false); } if (expressions.length === 0 && blockers.every((b) => b.settled)) { fn(node); + + // This is necessary because it is not guaranteed that the render function will + // advance the hydration node to $.async's end marker: it may stop at an inner + // block's end marker (in case of an inner if block for example), but it also may + // stop at the correct $.async end marker (in case of component child) - hence + // we can't just use hydrate_next() + // TODO this feels indicative of a bug elsewhere; ideally we wouldn't need + // to double-traverse in the already-resolved case + if (was_hydrating) { + set_hydrate_node(end); + } + return; } @@ -39,7 +53,6 @@ export function async(node, blockers = [], expressions = [], fn) { if (was_hydrating) { var previous_hydrate_node = hydrate_node; - var end = skip_nodes(false); set_hydrate_node(end); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-hydration/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/Child.svelte new file mode 100644 index 0000000000..02ef294d99 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/Child.svelte @@ -0,0 +1,5 @@ + + +{b} diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/_config.js new file mode 100644 index 0000000000..8132e9c522 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/_config.js @@ -0,0 +1,11 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, `

hello

true
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-hydration/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/main.svelte new file mode 100644 index 0000000000..3b08d41640 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-hydration/main.svelte @@ -0,0 +1,18 @@ + + +{#if a} +
+ {#if b} +

hello

+ {/if} +
+
+ +
+{/if} From 989492f0572cb6dd4beaf621dfbba0bcd409913c Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:01:41 +0100 Subject: [PATCH 35/71] fix: use "set up" (verb) instead of "setup" (noun) in comments (#17632) "Setup" is a noun/adjective, while "set up" is the verb form. --- documentation/docs/07-misc/02-testing.md | 2 +- packages/svelte/tests/signals/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md index 85db7fc01f..c1bc69c1c2 100644 --- a/documentation/docs/07-misc/02-testing.md +++ b/documentation/docs/07-misc/02-testing.md @@ -181,7 +181,7 @@ export default defineConfig({ /* ... */ ], test: { - // If you are testing components client-side, you need to setup a DOM environment. + // If you are testing components client-side, you need to set up a DOM environment. // If not all your files should have this environment, you can use a // `// @vitest-environment jsdom` comment at the top of the test files instead. environment: 'jsdom' diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 23c4bb42f9..5486ccdb45 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -21,7 +21,7 @@ import { disable_async_mode_flag, enable_async_mode_flag } from '../../src/inter /** * @param runes runes mode - * @param fn A function that returns a function because we first need to setup all the signals + * @param fn A function that returns a function because we first need to set up all the signals * and then execute the test in order to simulate a real component */ function run_test(runes: boolean, fn: (runes: boolean) => () => void) { From 01db7b8c2a0dbc7eb1fb8f1eff116f2b5b5b3ba9 Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:03:14 +0100 Subject: [PATCH 36/71] fix: update migration guide to reflect current passive events (#17631) Remove `onwheel` and `onmousewheel` from the list of passive events in the v5 migration guide. Since #13322, only `ontouchstart` and `ontouchmove` are passive by default, matching the basic-markup docs. Closes #17430 --- documentation/docs/07-misc/07-v5-migration-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/07-misc/07-v5-migration-guide.md b/documentation/docs/07-misc/07-v5-migration-guide.md index 40cbc3bd9e..5a80734b7a 100644 --- a/documentation/docs/07-misc/07-v5-migration-guide.md +++ b/documentation/docs/07-misc/07-v5-migration-guide.md @@ -778,9 +778,9 @@ In Svelte 4, doing the following triggered reactivity: This is because the Svelte compiler treated the assignment to `foo.value` as an instruction to update anything that referenced `foo`. In Svelte 5, reactivity is determined at runtime rather than compile time, so you should define `value` as a reactive `$state` field on the `Foo` class. Wrapping `new Foo()` with `$state(...)` will have no effect — only vanilla objects and arrays are made deeply reactive. -### Touch and wheel events are passive +### Touch events are passive -When using `onwheel`, `onmousewheel`, `ontouchstart` and `ontouchmove` event attributes, the handlers are [passive](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#using_passive_listeners) to align with browser defaults. This greatly improves responsiveness by allowing the browser to scroll the document immediately, rather than waiting to see if the event handler calls `event.preventDefault()`. +When using `ontouchstart` and `ontouchmove` event attributes, the handlers are [passive](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#using_passive_listeners) to align with browser defaults. This greatly improves responsiveness by allowing the browser to scroll the document immediately, rather than waiting to see if the event handler calls `event.preventDefault()`. In the very rare cases that you need to prevent these event defaults, you should use [`on`](/docs/svelte/svelte-events#on) instead (for example inside an action). From 0c715afdab2c3077b32793a41a7ddccb3be65c64 Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:03:58 +0100 Subject: [PATCH 37/71] fix: correct spelling errors in test files (#17630) Fix "wich" -> "which", "aswell" -> "as well" in CSS global-block test, and "occurence" -> "occurrence" in sourcemaps test type definition. --- packages/svelte/tests/css/samples/global-block/expected.css | 4 ++-- packages/svelte/tests/css/samples/global-block/input.svelte | 4 ++-- packages/svelte/tests/sourcemaps/test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/svelte/tests/css/samples/global-block/expected.css b/packages/svelte/tests/css/samples/global-block/expected.css index be1838fd98..ef68280093 100644 --- a/packages/svelte/tests/css/samples/global-block/expected.css +++ b/packages/svelte/tests/css/samples/global-block/expected.css @@ -39,7 +39,7 @@ /*}*/ } - /* ...wich is equivalent to `div :global { &.x { ...} }` ... */ + /* ...which is equivalent to `div :global { &.x { ...} }` ... */ div.svelte-xyz { &.x { color: green; @@ -51,7 +51,7 @@ color: green; } - /* ...and therefore `div { :global.x { ... }` aswell */ + /* ...and therefore `div { :global.x { ... }` as well */ div.svelte-xyz { &.x { color: green; diff --git a/packages/svelte/tests/css/samples/global-block/input.svelte b/packages/svelte/tests/css/samples/global-block/input.svelte index 86d438031a..1a9993bfe0 100644 --- a/packages/svelte/tests/css/samples/global-block/input.svelte +++ b/packages/svelte/tests/css/samples/global-block/input.svelte @@ -41,7 +41,7 @@ } } - /* ...wich is equivalent to `div :global { &.x { ...} }` ... */ + /* ...which is equivalent to `div :global { &.x { ...} }` ... */ div :global { &.x { color: green; @@ -53,7 +53,7 @@ color: green; } - /* ...and therefore `div { :global.x { ... }` aswell */ + /* ...and therefore `div { :global.x { ... }` as well */ div { :global.x { color: green; diff --git a/packages/svelte/tests/sourcemaps/test.ts b/packages/svelte/tests/sourcemaps/test.ts index 0ac0e6f905..6749483079 100644 --- a/packages/svelte/tests/sourcemaps/test.ts +++ b/packages/svelte/tests/sourcemaps/test.ts @@ -8,9 +8,9 @@ import { decode } from '@jridgewell/sourcemap-codec'; type SourceMapEntry = | string | { - /** If not the first occurence, but the nth should be found */ + /** If not the first occurrence, but the nth should be found */ idxOriginal?: number; - /** If not the first occurence, but the nth should be found */ + /** If not the first occurrence, but the nth should be found */ idxGenerated?: number; /** The original string to find */ str: string; From 4453e4895d407df6895f481257b675a43ed702c6 Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:04:42 +0100 Subject: [PATCH 38/71] fix: fix spelling errors in media-query and test file (#17628) - media-query.js: parentehesis -> parenthesis - error-boundary-12/main.svelte: occured -> occurred --- packages/svelte/src/reactivity/media-query.js | 2 +- .../tests/runtime-runes/samples/error-boundary-12/main.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index d286709719..6d1d084298 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -7,7 +7,7 @@ const parenthesis_regex = /\(.+\)/; // // eg: new MediaQuery('screen') // -// however because of the auto-parenthesis logic in the constructor since there's no parentehesis +// however because of the auto-parenthesis logic in the constructor since there's no parenthesis // in the media query they'll be surrounded by parenthesis // // however we can check if the media query is only composed of these keywords diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-12/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-12/main.svelte index d9dee1e2b0..748acab91e 100644 --- a/packages/svelte/tests/runtime-runes/samples/error-boundary-12/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-12/main.svelte @@ -15,6 +15,6 @@ {d} {#snippet failed()} -

Error occured

+

Error occurred

{/snippet} From f71a6813b7bf9c53b1dce0b5f3a1232f55b1f26a Mon Sep 17 00:00:00 2001 From: Artyom Alekseevich <47069814+FrankFMY@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:13:54 +0100 Subject: [PATCH 39/71] fix: treat menu element like ul/ol for a11y role checks (#17638) * treat menu element like ul/ol for a11y role checks The element has the same implicit role (list) as
    and
      , so it should receive the same treatment in a11y checks: - Allow without redundant role warning (CSS list-style:none can remove semantics, role restores them) - Allow with interactive roles like menu, menubar, radiogroup, tablist, tree, treegrid (same exceptions as ul/ol) Fixes #8529 * changeset --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/fix-menu-element-a11y-roles.md | 5 +++++ .../2-analyze/visitors/shared/a11y/constants.js | 1 + .../phases/2-analyze/visitors/shared/a11y/index.js | 2 +- .../input.svelte | 7 +++++++ .../samples/a11y-no-redundant-roles/input.svelte | 4 ++-- .../samples/a11y-no-redundant-roles/warnings.json | 12 ------------ 6 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-menu-element-a11y-roles.md diff --git a/.changeset/fix-menu-element-a11y-roles.md b/.changeset/fix-menu-element-a11y-roles.md new file mode 100644 index 0000000000..75cfe8d310 --- /dev/null +++ b/.changeset/fix-menu-element-a11y-roles.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: treat `` like `
        `/`
          ` for a11y role checks diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/constants.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/constants.js index 684ea92094..49099c2cb1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/constants.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/constants.js @@ -174,6 +174,7 @@ export const input_type_to_implicit_role = new Map([ export const a11y_non_interactive_element_to_interactive_role_exceptions = { ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], + menu: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], table: ['grid'], td: ['gridcell'], diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js index f5f4982ed2..45de8b10a1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js @@ -167,7 +167,7 @@ export function check_element(node, context) { if ( current_role === get_implicit_role(node.name, attribute_map) && //
            is ok because CSS list-style:none removes the semantics and this is a way to bring them back - !['ul', 'ol', 'li'].includes(node.name) && + !['ul', 'ol', 'li', 'menu'].includes(node.name) && // is ok because without href the a tag doesn't have a role of link !(node.name === 'a' && !attribute_map.has('href')) ) { diff --git a/packages/svelte/tests/validator/samples/a11y-no-noninteractive-element-to-interactive-role/input.svelte b/packages/svelte/tests/validator/samples/a11y-no-noninteractive-element-to-interactive-role/input.svelte index e5db8719b0..edfa3eeada 100644 --- a/packages/svelte/tests/validator/samples/a11y-no-noninteractive-element-to-interactive-role/input.svelte +++ b/packages/svelte/tests/validator/samples/a11y-no-noninteractive-element-to-interactive-role/input.svelte @@ -69,6 +69,13 @@
          • + + + + + + +
            diff --git a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte index 2ccd765aa2..9e06ebe420 100644 --- a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte +++ b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/input.svelte @@ -22,7 +22,7 @@
            - +
            @@ -44,5 +44,5 @@
            - + diff --git a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json index 861f000aef..e041037d71 100644 --- a/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json +++ b/packages/svelte/tests/validator/samples/a11y-no-redundant-roles/warnings.json @@ -275,18 +275,6 @@ "line": 24 } }, - { - "code": "a11y_no_redundant_roles", - "end": { - "column": 17, - "line": 25 - }, - "message": "Redundant role 'list'", - "start": { - "column": 6, - "line": 25 - } - }, { "code": "a11y_no_redundant_roles", "end": { From bd7b8aa10807be8884f5e7224340f8d980d971ec Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Feb 2026 11:37:25 -0500 Subject: [PATCH 40/71] chore: fix broken css test (#17644) --- packages/svelte/tests/css/samples/global-block/_config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/tests/css/samples/global-block/_config.js b/packages/svelte/tests/css/samples/global-block/_config.js index 18a56e9a97..84b4989f76 100644 --- a/packages/svelte/tests/css/samples/global-block/_config.js +++ b/packages/svelte/tests/css/samples/global-block/_config.js @@ -9,12 +9,12 @@ export default test({ start: { line: 73, column: 1, - character: 964 + character: 966 }, end: { line: 73, column: 16, - character: 979 + character: 981 } }, { @@ -23,12 +23,12 @@ export default test({ start: { line: 104, column: 29, - character: 1270 + character: 1272 }, end: { line: 104, column: 43, - character: 1284 + character: 1286 } } ] From 57cb393796841538c1b38ef99dd2be48815604a5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 6 Feb 2026 11:57:49 -0500 Subject: [PATCH 41/71] fix: allow NaN in key blocks (#17642) * fix: allow NaN in key blocks * lol whoops * Update packages/svelte/src/internal/client/dom/blocks/key.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/clear-olives-share.md | 5 +++++ .../src/internal/client/dom/blocks/key.js | 7 +++++++ .../runtime-runes/samples/key-nan/_config.js | 17 +++++++++++++++++ .../runtime-runes/samples/key-nan/main.svelte | 10 ++++++++++ 4 files changed, 39 insertions(+) create mode 100644 .changeset/clear-olives-share.md create mode 100644 packages/svelte/tests/runtime-runes/samples/key-nan/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/key-nan/main.svelte diff --git a/.changeset/clear-olives-share.md b/.changeset/clear-olives-share.md new file mode 100644 index 0000000000..975b2b21b6 --- /dev/null +++ b/.changeset/clear-olives-share.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: allow `{#key NaN}` diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index 849b1c2447..dd40d8a1b8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -4,6 +4,8 @@ import { block } from '../../reactivity/effects.js'; import { hydrate_next, hydrating } from '../hydration.js'; import { BranchManager } from './branches.js'; +const NAN = Symbol('NaN'); + /** * @template V * @param {TemplateNode} node @@ -23,6 +25,11 @@ export function key(node, get_key, render_fn) { block(() => { var key = get_key(); + // NaN !== NaN, hence we do this workaround to not trigger remounts unnecessarily + if (key !== key) { + key = /** @type {any} */ (NAN); + } + // key blocks in Svelte <5 had stupid semantics if (legacy && key !== null && typeof key === 'object') { key = /** @type {V} */ ({}); diff --git a/packages/svelte/tests/runtime-runes/samples/key-nan/_config.js b/packages/svelte/tests/runtime-runes/samples/key-nan/_config.js new file mode 100644 index 0000000000..bd0dd06761 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/key-nan/_config.js @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

            it rendered

            ', + + test({ assert, target, logs }) { + assert.deepEqual(logs, ['rendering']); + + const btn = target.querySelector('button'); + flushSync(() => btn?.click()); + + // should not re-render + assert.deepEqual(logs, ['rendering']); + assert.htmlEqual(target.innerHTML, '

            it rendered

            '); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/key-nan/main.svelte b/packages/svelte/tests/runtime-runes/samples/key-nan/main.svelte new file mode 100644 index 0000000000..24f7f2d179 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/key-nan/main.svelte @@ -0,0 +1,10 @@ + + + + +{#key x} + {console.log('rendering')} +

            it rendered

            +{/key} From 863c9d6875470ad7699614fe278c0d99903d30f8 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 6 Feb 2026 10:27:13 -0800 Subject: [PATCH 42/71] chore: wrap JSDoc URLs in @see and @link tags (#17617) * docs: wrap JSDoc URLs in @see and @link tags * fix: move curly brace to end of URL * chore: add changeset * add link text * regenerate --------- Co-authored-by: Rich Harris --- .changeset/loud-bottles-own.md | 5 ++++ packages/svelte/src/ambient.d.ts | 28 +++++++++---------- .../src/compiler/phases/1-parse/read/style.js | 2 +- .../client/dom/elements/bindings/size.js | 5 ++-- .../samples/transition-component/_config.js | 2 +- packages/svelte/types/index.d.ts | 28 +++++++++---------- 6 files changed, 37 insertions(+), 33 deletions(-) create mode 100644 .changeset/loud-bottles-own.md diff --git a/.changeset/loud-bottles-own.md b/.changeset/loud-bottles-own.md new file mode 100644 index 0000000000..493dc6c47f --- /dev/null +++ b/.changeset/loud-bottles-own.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: wrap JSDoc URLs in `@see` and `@link` tags diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index ec5b799470..159a568477 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -16,7 +16,7 @@ declare module '*.svelte' { * let count = $state(0); * ``` * - * https://svelte.dev/docs/svelte/$state + * @see {@link https://svelte.dev/docs/svelte/$state Documentation} * * @param initial The initial value */ @@ -126,7 +126,7 @@ declare namespace $state { * * ``` * - * https://svelte.dev/docs/svelte/$state#$state.raw + * @see {@link https://svelte.dev/docs/svelte/$state#$state.raw Documentation} * * @param initial The initial value */ @@ -147,7 +147,7 @@ declare namespace $state { * * ``` * - * https://svelte.dev/docs/svelte/$state#$state.snapshot + * @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation} * * @param state The value to snapshot */ @@ -187,7 +187,7 @@ declare namespace $state { * let double = $derived(count * 2); * ``` * - * https://svelte.dev/docs/svelte/$derived + * @see {@link https://svelte.dev/docs/svelte/$derived Documentation} * * @param expression The derived state expression */ @@ -209,7 +209,7 @@ declare namespace $derived { * }); * ``` * - * https://svelte.dev/docs/svelte/$derived#$derived.by + * @see {@link https://svelte.dev/docs/svelte/$derived#$derived.by Documentation} */ export function by(fn: () => T): T; @@ -251,7 +251,7 @@ declare namespace $derived { * * Does not run during server-side rendering. * - * https://svelte.dev/docs/svelte/$effect + * @see {@link https://svelte.dev/docs/svelte/$effect Documentation} * @param fn The function to execute */ declare function $effect(fn: () => void | (() => void)): void; @@ -270,7 +270,7 @@ declare namespace $effect { * * Does not run during server-side rendering. * - * https://svelte.dev/docs/svelte/$effect#$effect.pre + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.pre Documentation} * @param fn The function to execute */ export function pre(fn: () => void | (() => void)): void; @@ -278,7 +278,7 @@ declare namespace $effect { /** * Returns the number of promises that are pending in the current boundary, not including child boundaries. * - * https://svelte.dev/docs/svelte/$effect#$effect.pending + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.pending Documentation} */ export function pending(): number; @@ -300,7 +300,7 @@ declare namespace $effect { * * This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects. * - * https://svelte.dev/docs/svelte/$effect#$effect.tracking + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.tracking Documentation} */ export function tracking(): boolean; @@ -328,7 +328,7 @@ declare namespace $effect { * * ``` * - * https://svelte.dev/docs/svelte/$effect#$effect.root + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.root Documentation} */ export function root(fn: () => void | (() => void)): () => void; @@ -364,7 +364,7 @@ declare namespace $effect { * let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props(); * ``` * - * https://svelte.dev/docs/svelte/$props + * @see {@link https://svelte.dev/docs/svelte/$props Documentation} */ declare function $props(): any; @@ -410,7 +410,7 @@ declare namespace $props { * let { propName = $bindable() }: { propName: boolean } = $props(); * ``` * - * https://svelte.dev/docs/svelte/$bindable + * @see {@link https://svelte.dev/docs/svelte/$bindable Documentation} */ declare function $bindable(fallback?: T): T; @@ -456,7 +456,7 @@ declare namespace $bindable { * $inspect(x, y).with(() => { debugger; }); * ``` * - * https://svelte.dev/docs/svelte/$inspect + * @see {@link https://svelte.dev/docs/svelte/$inspect Documentation} */ declare function $inspect( ...values: T @@ -522,7 +522,7 @@ declare namespace $inspect { * * Only available inside custom element components, and only on the client-side. * - * https://svelte.dev/docs/svelte/$host + * @see {@link https://svelte.dev/docs/svelte/$host Documentation} */ declare function $host(): El; diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 55647b2c94..4f2db349cd 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -568,7 +568,7 @@ function read_attribute_value(parser) { } /** - * https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + * @see {@link https://www.w3.org/TR/css-syntax-3/#ident-token-diagram CSS Syntax Module Level 3} * @param {Parser} parser */ function read_identifier(parser) { diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/size.js b/packages/svelte/src/internal/client/dom/elements/bindings/size.js index a76c70aab1..016ee5a547 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/size.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/size.js @@ -2,9 +2,8 @@ import { effect, teardown } from '../../../reactivity/effects.js'; import { untrack } from '../../../runtime.js'; /** - * Resize observer singleton. - * One listener per element only! - * https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ + * We create one listener for all elements + * @see {@link https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ Explanation} */ class ResizeObserverSingleton { /** */ diff --git a/packages/svelte/tests/runtime-runes/samples/transition-component/_config.js b/packages/svelte/tests/runtime-runes/samples/transition-component/_config.js index 414a8b0cc2..1bdf3a5a2e 100644 --- a/packages/svelte/tests/runtime-runes/samples/transition-component/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/transition-component/_config.js @@ -3,7 +3,7 @@ import { test } from '../../test'; /** * $.component() should not break transition - * https://github.com/sveltejs/svelte/issues/13645 + * @see {@link https://github.com/sveltejs/svelte/issues/13645} */ export default test({ test({ assert, raf, target }) { diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 730e0ff655..62c0e210be 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -3188,7 +3188,7 @@ declare module 'svelte/types/compiler/interfaces' { * let count = $state(0); * ``` * - * https://svelte.dev/docs/svelte/$state + * @see {@link https://svelte.dev/docs/svelte/$state Documentation} * * @param initial The initial value */ @@ -3298,7 +3298,7 @@ declare namespace $state { * * ``` * - * https://svelte.dev/docs/svelte/$state#$state.raw + * @see {@link https://svelte.dev/docs/svelte/$state#$state.raw Documentation} * * @param initial The initial value */ @@ -3319,7 +3319,7 @@ declare namespace $state { * * ``` * - * https://svelte.dev/docs/svelte/$state#$state.snapshot + * @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation} * * @param state The value to snapshot */ @@ -3359,7 +3359,7 @@ declare namespace $state { * let double = $derived(count * 2); * ``` * - * https://svelte.dev/docs/svelte/$derived + * @see {@link https://svelte.dev/docs/svelte/$derived Documentation} * * @param expression The derived state expression */ @@ -3381,7 +3381,7 @@ declare namespace $derived { * }); * ``` * - * https://svelte.dev/docs/svelte/$derived#$derived.by + * @see {@link https://svelte.dev/docs/svelte/$derived#$derived.by Documentation} */ export function by(fn: () => T): T; @@ -3423,7 +3423,7 @@ declare namespace $derived { * * Does not run during server-side rendering. * - * https://svelte.dev/docs/svelte/$effect + * @see {@link https://svelte.dev/docs/svelte/$effect Documentation} * @param fn The function to execute */ declare function $effect(fn: () => void | (() => void)): void; @@ -3442,7 +3442,7 @@ declare namespace $effect { * * Does not run during server-side rendering. * - * https://svelte.dev/docs/svelte/$effect#$effect.pre + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.pre Documentation} * @param fn The function to execute */ export function pre(fn: () => void | (() => void)): void; @@ -3450,7 +3450,7 @@ declare namespace $effect { /** * Returns the number of promises that are pending in the current boundary, not including child boundaries. * - * https://svelte.dev/docs/svelte/$effect#$effect.pending + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.pending Documentation} */ export function pending(): number; @@ -3472,7 +3472,7 @@ declare namespace $effect { * * This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects. * - * https://svelte.dev/docs/svelte/$effect#$effect.tracking + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.tracking Documentation} */ export function tracking(): boolean; @@ -3500,7 +3500,7 @@ declare namespace $effect { * * ``` * - * https://svelte.dev/docs/svelte/$effect#$effect.root + * @see {@link https://svelte.dev/docs/svelte/$effect#$effect.root Documentation} */ export function root(fn: () => void | (() => void)): () => void; @@ -3536,7 +3536,7 @@ declare namespace $effect { * let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props(); * ``` * - * https://svelte.dev/docs/svelte/$props + * @see {@link https://svelte.dev/docs/svelte/$props Documentation} */ declare function $props(): any; @@ -3582,7 +3582,7 @@ declare namespace $props { * let { propName = $bindable() }: { propName: boolean } = $props(); * ``` * - * https://svelte.dev/docs/svelte/$bindable + * @see {@link https://svelte.dev/docs/svelte/$bindable Documentation} */ declare function $bindable(fallback?: T): T; @@ -3628,7 +3628,7 @@ declare namespace $bindable { * $inspect(x, y).with(() => { debugger; }); * ``` * - * https://svelte.dev/docs/svelte/$inspect + * @see {@link https://svelte.dev/docs/svelte/$inspect Documentation} */ declare function $inspect( ...values: T @@ -3694,7 +3694,7 @@ declare namespace $inspect { * * Only available inside custom element components, and only on the client-side. * - * https://svelte.dev/docs/svelte/$host + * @see {@link https://svelte.dev/docs/svelte/$host Documentation} */ declare function $host(): El; From bb00d5b6e75da491bcee7860d3428458fd2f46ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:05:08 -0500 Subject: [PATCH 43/71] Version Packages (#17615) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/chatty-mammals-find.md | 5 ----- .changeset/clear-olives-share.md | 5 ----- .changeset/fix-each-bind-store-logical.md | 5 ----- .changeset/fix-menu-element-a11y-roles.md | 5 ----- .changeset/forty-worlds-attack.md | 5 ----- .changeset/loud-bottles-own.md | 5 ----- .changeset/orange-ants-greet.md | 5 ----- .changeset/poor-students-nail.md | 5 ----- .changeset/sharp-snakes-poke.md | 5 ----- .changeset/tiny-owls-pay.md | 5 ----- packages/svelte/CHANGELOG.md | 26 +++++++++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 13 files changed, 28 insertions(+), 52 deletions(-) delete mode 100644 .changeset/chatty-mammals-find.md delete mode 100644 .changeset/clear-olives-share.md delete mode 100644 .changeset/fix-each-bind-store-logical.md delete mode 100644 .changeset/fix-menu-element-a11y-roles.md delete mode 100644 .changeset/forty-worlds-attack.md delete mode 100644 .changeset/loud-bottles-own.md delete mode 100644 .changeset/orange-ants-greet.md delete mode 100644 .changeset/poor-students-nail.md delete mode 100644 .changeset/sharp-snakes-poke.md delete mode 100644 .changeset/tiny-owls-pay.md diff --git a/.changeset/chatty-mammals-find.md b/.changeset/chatty-mammals-find.md deleted file mode 100644 index 373dc0059a..0000000000 --- a/.changeset/chatty-mammals-find.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure infinite effect loops are cleared after flushing diff --git a/.changeset/clear-olives-share.md b/.changeset/clear-olives-share.md deleted file mode 100644 index 975b2b21b6..0000000000 --- a/.changeset/clear-olives-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: allow `{#key NaN}` diff --git a/.changeset/fix-each-bind-store-logical.md b/.changeset/fix-each-bind-store-logical.md deleted file mode 100644 index 1327015124..0000000000 --- a/.changeset/fix-each-bind-store-logical.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: detect store in each block expression regardless of AST shape diff --git a/.changeset/fix-menu-element-a11y-roles.md b/.changeset/fix-menu-element-a11y-roles.md deleted file mode 100644 index 75cfe8d310..0000000000 --- a/.changeset/fix-menu-element-a11y-roles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: treat `
            ` like `
              `/`
                ` for a11y role checks diff --git a/.changeset/forty-worlds-attack.md b/.changeset/forty-worlds-attack.md deleted file mode 100644 index 52bb3644c0..0000000000 --- a/.changeset/forty-worlds-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: add vite-ignore comment inside dynamic crypto import diff --git a/.changeset/loud-bottles-own.md b/.changeset/loud-bottles-own.md deleted file mode 100644 index 493dc6c47f..0000000000 --- a/.changeset/loud-bottles-own.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: wrap JSDoc URLs in `@see` and `@link` tags diff --git a/.changeset/orange-ants-greet.md b/.changeset/orange-ants-greet.md deleted file mode 100644 index 6f7c684fee..0000000000 --- a/.changeset/orange-ants-greet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': minor ---- - -feat: allow use of createContext when instantiating components programmatically diff --git a/.changeset/poor-students-nail.md b/.changeset/poor-students-nail.md deleted file mode 100644 index cee650c002..0000000000 --- a/.changeset/poor-students-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: properly hydrate already-resolved async blocks diff --git a/.changeset/sharp-snakes-poke.md b/.changeset/sharp-snakes-poke.md deleted file mode 100644 index 7f7f8aa7b2..0000000000 --- a/.changeset/sharp-snakes-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: emit `each_key_duplicate` error in production diff --git a/.changeset/tiny-owls-pay.md b/.changeset/tiny-owls-pay.md deleted file mode 100644 index ac25500258..0000000000 --- a/.changeset/tiny-owls-pay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: exit resolved async blocks on correct node when hydrating diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index a71effd5dd..b251e778ef 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,31 @@ # svelte +## 5.50.0 + +### Minor Changes + +- feat: allow use of createContext when instantiating components programmatically ([#17575](https://github.com/sveltejs/svelte/pull/17575)) + +### Patch Changes + +- fix: ensure infinite effect loops are cleared after flushing ([#17601](https://github.com/sveltejs/svelte/pull/17601)) + +- fix: allow `{#key NaN}` ([#17642](https://github.com/sveltejs/svelte/pull/17642)) + +- fix: detect store in each block expression regardless of AST shape ([#17636](https://github.com/sveltejs/svelte/pull/17636)) + +- fix: treat `` like `
                  `/`
                    ` for a11y role checks ([#17638](https://github.com/sveltejs/svelte/pull/17638)) + +- fix: add vite-ignore comment inside dynamic crypto import ([#17623](https://github.com/sveltejs/svelte/pull/17623)) + +- chore: wrap JSDoc URLs in `@see` and `@link` tags ([#17617](https://github.com/sveltejs/svelte/pull/17617)) + +- fix: properly hydrate already-resolved async blocks ([#17641](https://github.com/sveltejs/svelte/pull/17641)) + +- fix: emit `each_key_duplicate` error in production ([#16724](https://github.com/sveltejs/svelte/pull/16724)) + +- fix: exit resolved async blocks on correct node when hydrating ([#17640](https://github.com/sveltejs/svelte/pull/17640)) + ## 5.49.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 48f492783f..82d0d1a8ed 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.49.2", + "version": "5.50.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 31538c3fe7..3aefb2e1f6 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.49.2'; +export const VERSION = '5.50.0'; export const PUBLIC_VERSION = '5'; From 9749ee869c4a3473a50a3a42daa2ee8896d41898 Mon Sep 17 00:00:00 2001 From: 7nik Date: Sat, 7 Feb 2026 01:09:42 +0200 Subject: [PATCH 44/71] chore: add xhtml tests (#17597) * add xhtml tests * unused * tweak * more tests * tweak * we don't need to actually check the HTML - if it's malformed in SSR or mount, it will throw * same here * unused, so we can revert this * and this --------- Co-authored-by: Rich Harris --- eslint.config.js | 1 + packages/svelte/tests/html_equal.js | 16 ++++++++-------- .../samples/attribute-quotes/_config.js | 3 +++ .../samples/attribute-quotes/main.svelte | 2 ++ .../samples/autoclosed-tags/_config.js | 3 +++ .../samples/autoclosed-tags/main.svelte | 6 ++++++ .../samples/boolean-attributes/_config.js | 5 +++++ .../samples/boolean-attributes/main.svelte | 1 + .../samples/comment-marker/_config.js | 3 +++ .../samples/comment-marker/main.svelte | 1 + .../runtime-xhtml/samples/is-xhtml/_config.js | 8 ++++++++ .../samples/is-xhtml/main.svelte | 5 +++++ .../samples/void-tags/_config.js | 3 +++ .../samples/void-tags/main.svelte | 15 +++++++++++++++ packages/svelte/tests/runtime-xhtml/test.ts | 9 +++++++++ vitest-xhtml-environment.ts | 19 +++++++++++++++++++ 16 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/samples/comment-marker/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/comment-marker/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/samples/is-xhtml/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/is-xhtml/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/samples/void-tags/_config.js create mode 100644 packages/svelte/tests/runtime-xhtml/samples/void-tags/main.svelte create mode 100644 packages/svelte/tests/runtime-xhtml/test.ts create mode 100644 vitest-xhtml-environment.ts diff --git a/eslint.config.js b/eslint.config.js index 0e0e0bc729..04d9294394 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -102,6 +102,7 @@ export default [ 'playgrounds/sandbox/**', // exclude top level config files '*.config.js', + 'vitest-xhtml-environment.ts', // documentation can contain invalid examples 'documentation', 'tmp/**' diff --git a/packages/svelte/tests/html_equal.js b/packages/svelte/tests/html_equal.js index 76a4a957a5..cdb8f52e37 100644 --- a/packages/svelte/tests/html_equal.js +++ b/packages/svelte/tests/html_equal.js @@ -9,7 +9,9 @@ function clean_children(node, opts) { let previous = null; let has_element_children = false; let template = - node.nodeName === 'TEMPLATE' ? /** @type {HTMLTemplateElement} */ (node) : undefined; + node.nodeName.toUpperCase() === 'TEMPLATE' + ? /** @type {HTMLTemplateElement} */ (node) + : undefined; if (template) { const div = document.createElement('div'); @@ -23,22 +25,20 @@ function clean_children(node, opts) { }); attributes.forEach((attr) => { - node.removeAttribute(attr.name); + if (attr.name !== 'xmlns') node.removeAttribute(attr.name); }); - attributes.forEach((attr) => { + attributes.forEach(({ name, value }) => { // Strip out the special onload/onerror hydration events from the test output - if ((attr.name === 'onload' || attr.name === 'onerror') && attr.value === 'this.__e=event') { + if (['onload', 'onerror', 'xmlns'].includes(name) && value === 'this.__e=event') { return; } - let value = attr.value; - - if (attr.name === 'class') { + if (name === 'class') { value = value.replace(/svelte-\w+/, 'svelte-xyz123'); } - node.setAttribute(attr.name, value); + node.setAttribute(name, value); }); for (let child of [...node.childNodes]) { diff --git a/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/_config.js b/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/main.svelte new file mode 100644 index 0000000000..82db611c12 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/attribute-quotes/main.svelte @@ -0,0 +1,2 @@ +
                    +
                    diff --git a/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/_config.js b/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/main.svelte new file mode 100644 index 0000000000..18a4463ce1 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/autoclosed-tags/main.svelte @@ -0,0 +1,6 @@ +
                    +
                    +

                    + + +
                    diff --git a/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js new file mode 100644 index 0000000000..f965e04b02 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + skip: true +}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/main.svelte new file mode 100644 index 0000000000..3d2fc88d10 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/runtime-xhtml/samples/comment-marker/_config.js b/packages/svelte/tests/runtime-xhtml/samples/comment-marker/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/comment-marker/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/comment-marker/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/comment-marker/main.svelte new file mode 100644 index 0000000000..8dd87ce81e --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/comment-marker/main.svelte @@ -0,0 +1 @@ +{#each [1,2] as i}{i}{/each} diff --git a/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/_config.js b/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/_config.js new file mode 100644 index 0000000000..52dddb4ad7 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client'], + test({ assert, target }) { + assert.htmlEqual(target.innerHTML, `
                    div
                    `); + } +}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/main.svelte new file mode 100644 index 0000000000..27a4378d0f --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/is-xhtml/main.svelte @@ -0,0 +1,5 @@ + +
                    {nodeName}
                    diff --git a/packages/svelte/tests/runtime-xhtml/samples/void-tags/_config.js b/packages/svelte/tests/runtime-xhtml/samples/void-tags/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/void-tags/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-xhtml/samples/void-tags/main.svelte b/packages/svelte/tests/runtime-xhtml/samples/void-tags/main.svelte new file mode 100644 index 0000000000..08ea2838b3 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/samples/void-tags/main.svelte @@ -0,0 +1,15 @@ + + +
                    + + +
                    + + + + + + + + + diff --git a/packages/svelte/tests/runtime-xhtml/test.ts b/packages/svelte/tests/runtime-xhtml/test.ts new file mode 100644 index 0000000000..416300dc58 --- /dev/null +++ b/packages/svelte/tests/runtime-xhtml/test.ts @@ -0,0 +1,9 @@ +// @vitest-environment vitest-xhtml-environment.ts + +import { runtime_suite, ok } from '../runtime-legacy/shared'; + +const { test, run } = runtime_suite(true); + +export { test, ok }; + +await run(__dirname); diff --git a/vitest-xhtml-environment.ts b/vitest-xhtml-environment.ts new file mode 100644 index 0000000000..dc8b271440 --- /dev/null +++ b/vitest-xhtml-environment.ts @@ -0,0 +1,19 @@ +import { type Environment, builtinEnvironments } from 'vitest/environments'; + +const xhtml_page = ` + +`; + +export default { + name: 'jsdom-xhtml', + transformMode: 'web', + setup(global, { jsdom = {} }) { + return builtinEnvironments.jsdom.setup(global, { + jsdom: { + ...jsdom, + html: xhtml_page, + contentType: 'application/xhtml+xml' + } + }); + } +}; From 6ad6eb1bbf5bce809eb8e2f4495b422fc7c60c51 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:38:22 +0100 Subject: [PATCH 45/71] docs: add section on conditional attachments (#17599) --- documentation/docs/03-template-syntax/09-@attach.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/documentation/docs/03-template-syntax/09-@attach.md b/documentation/docs/03-template-syntax/09-@attach.md index b25fbb32a6..8814ead487 100644 --- a/documentation/docs/03-template-syntax/09-@attach.md +++ b/documentation/docs/03-template-syntax/09-@attach.md @@ -82,6 +82,14 @@ Attachments can also be created inline ([demo](/playground/untitled#H4sIAAAAAAAA > [!NOTE] > The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state. +## Conditional attachments + +Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage: + +```svelte +
                    ...
                    +``` + ## Passing attachments to components When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments. From d047bc6e2a7238fadff45d09a779a17dc14aeafd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 7 Feb 2026 19:49:03 -0500 Subject: [PATCH 46/71] fix: render boolean attribute values as empty strings for XHTML compliance (#17648) --- .changeset/tired-cities-wink.md | 5 +++++ .../phases/3-transform/client/visitors/RegularElement.js | 5 +---- .../phases/3-transform/server/visitors/shared/element.js | 8 +------- .../runtime-xhtml/samples/boolean-attributes/_config.js | 4 +--- .../skip-static-subtree/_expected/server/index.svelte.js | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) create mode 100644 .changeset/tired-cities-wink.md diff --git a/.changeset/tired-cities-wink.md b/.changeset/tired-cities-wink.md new file mode 100644 index 0000000000..a319b5b2b0 --- /dev/null +++ b/.changeset/tired-cities-wink.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: render boolean attribute values as empty strings for XHTML compliance diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 720522beaf..f651586dd8 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -256,10 +256,7 @@ export function RegularElement(node, context) { } if (name !== 'class' || value) { - context.state.template.set_prop( - attribute.name, - is_boolean_attribute(name) && value === true ? undefined : value === true ? '' : value - ); + context.state.template.set_prop(attribute.name, value === true ? '' : value); } } else if (name === 'autofocus') { let { value } = build_attribute_value(attribute.value, context); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js index 979efef6b9..f4f491c056 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js @@ -235,13 +235,7 @@ export function build_element_attributes(node, context, transform) { if (name !== 'class' || literal_value) { context.state.template.push( - b.literal( - ` ${attribute.name}${ - is_boolean_attribute(name) && literal_value === true - ? '' - : `="${literal_value === true ? '' : String(literal_value)}"` - }` - ) + b.literal(` ${attribute.name}="${literal_value === true ? '' : String(literal_value)}"`) ); } diff --git a/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js index f965e04b02..f47bee71df 100644 --- a/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js +++ b/packages/svelte/tests/runtime-xhtml/samples/boolean-attributes/_config.js @@ -1,5 +1,3 @@ import { test } from '../../test'; -export default test({ - skip: true -}); +export default test({}); diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js index 7a9f6193d7..b1babb514c 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js @@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server'; export default function Skip_static_subtree($$renderer, $$props) { let { title, content } = $$props; - $$renderer.push(`
                    Home Away

                    ${$.escape(title)}

                    we don't need to traverse these nodes

                    or

                    these

                    ones

                    ${$.html(content)}

                    these

                    trailing

                    nodes

                    can

                    be

                    completely

                    ignored

                    ` in legacy (non-runes) components throws `effect_update_depth_exceeded` when the bound value comes from a `$:` reactive statement. **Root cause:** `setup_select_synchronization` created a `template_effect` that called `invalidate_inner_signals`, which reads and writes the same signals on every change — creating an infinite update loop when those signals feed back into derived state. **Fix:** Remove the effect-based synchronization entirely. Instead, populate `legacy_indirect_bindings` during the analyze phase for ``, collect scope references as indirect bindings on the bound variable - **`RegularElement.js` (transform)**: Remove `setup_select_synchronization` function and its call site - **`AssignmentExpression.js` (transform)**: When mutating a binding with indirect bindings, append `invalidate_inner_signals` call after the mutation ## Test plan - Added `binding-select-reactive-derived` test that reproduces the exact scenario from #13768 - All 3291 runtime-legacy tests pass (0 regressions) - All 2312 runtime-runes tests pass - All snapshot and compiler tests pass --------- Co-authored-by: Rich Harris --- .changeset/fix-select-bind-legacy-sync.md | 5 ++ .../2-analyze/visitors/RegularElement.js | 30 ++++++++++ .../client/visitors/AssignmentExpression.js | 23 ++++++- .../client/visitors/RegularElement.js | 60 ------------------- packages/svelte/src/compiler/phases/scope.js | 6 ++ .../_config.js | 37 ++++++++++++ .../main.svelte | 21 +++++++ 7 files changed, 120 insertions(+), 62 deletions(-) create mode 100644 .changeset/fix-select-bind-legacy-sync.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/main.svelte diff --git a/.changeset/fix-select-bind-legacy-sync.md b/.changeset/fix-select-bind-legacy-sync.md new file mode 100644 index 0000000000..bf363f0fc4 --- /dev/null +++ b/.changeset/fix-select-bind-legacy-sync.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: resolve `effect_update_depth_exceeded` when using `bind:value` on `` + // means we need to invalidate `bar` whenever `foo` is mutated + if (node.name === 'select' && !runes) { + for (const attribute of node.attributes) { + if ( + attribute.type === 'BindDirective' && + attribute.name === 'value' && + attribute.expression.type !== 'SequenceExpression' + ) { + const identifier = object(attribute.expression); + const binding = identifier && context.state.scope.get(identifier.name); + + if (binding) { + for (const name of context.state.scope.references.keys()) { + if (name === binding.node.name) continue; + const indirect = context.state.scope.get(name); + + if (indirect) { + binding.legacy_indirect_bindings.add(indirect); + } + } + } + + break; + } + } + } + // Special case: single expression tag child of option element -> add "fake" attribute // to ensure that value types are the same (else for example numbers would be strings) if ( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 0f6a619357..1379669e77 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -8,7 +8,7 @@ import { is_event_attribute } from '../../../../utils/ast.js'; import { dev, locate_node } from '../../../../state.js'; -import { should_proxy } from '../utils.js'; +import { build_getter, should_proxy } from '../utils.js'; import { visit_assignment_expression } from '../../shared/assignments.js'; import { validate_mutation } from './shared/utils.js'; import { get_rune } from '../../../scope.js'; @@ -147,7 +147,7 @@ function build_assignment(operator, left, right, context) { // mutation if (transform?.mutate) { - return transform.mutate( + let mutation = transform.mutate( object, b.assignment( operator, @@ -155,6 +155,25 @@ function build_assignment(operator, left, right, context) { /** @type {Expression} */ (context.visit(right)) ) ); + + if (binding.legacy_indirect_bindings.size > 0) { + mutation = b.sequence([ + mutation, + b.call( + '$.invalidate_inner_signals', + b.arrow( + [], + b.block( + Array.from(binding.legacy_indirect_bindings).map((binding) => + b.stmt(build_getter({ ...binding.node }, context.state)) + ) + ) + ) + ) + ]); + } + + return mutation; } // in cases like `(object.items ??= []).push(value)`, we may need to warn diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index f651586dd8..7469dcda5b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -199,10 +199,6 @@ export function RegularElement(node, context) { } } - if (node.name === 'select' && bindings.has('value')) { - setup_select_synchronization(/** @type {AST.BindDirective} */ (bindings.get('value')), context); - } - // Let bindings first, they can be used on attributes context.state.init.push(...lets); @@ -501,62 +497,6 @@ export function RegularElement(node, context) { context.state.template.pop_element(); } -/** - * Special case: if we have a value binding on a select element, we need to set up synchronization - * between the value binding and inner signals, for indirect updates - * @param {AST.BindDirective} value_binding - * @param {ComponentContext} context - */ -function setup_select_synchronization(value_binding, context) { - if (context.state.analysis.runes) return; - - let bound = value_binding.expression; - - if (bound.type === 'SequenceExpression') { - return; - } - - while (bound.type === 'MemberExpression') { - bound = /** @type {Identifier | MemberExpression} */ (bound.object); - } - - /** @type {string[]} */ - const names = []; - - for (const [name, refs] of context.state.scope.references) { - if ( - refs.length > 0 && - // prevent infinite loop - name !== bound.name - ) { - names.push(name); - } - } - - const invalidator = b.call( - '$.invalidate_inner_signals', - b.thunk( - b.block( - names.map((name) => { - const serialized = build_getter(b.id(name), context.state); - return b.stmt(serialized); - }) - ) - ) - ); - - context.state.init.push( - b.stmt( - b.call( - '$.template_effect', - b.thunk( - b.block([b.stmt(/** @type {Expression} */ (context.visit(bound))), b.stmt(invalidator)]) - ) - ) - ) - ); -} - /** * @param {AST.ClassDirective[]} class_directives * @param {ComponentContext} context diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 52efd93210..a320718f58 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -120,6 +120,12 @@ export class Binding { */ legacy_dependencies = []; + /** + * Bindings that should be invalidated when this binding is invalidated + * @type {Set} + */ + legacy_indirect_bindings = new Set(); + /** * Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() * @type {string | null} diff --git a/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/_config.js b/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/_config.js new file mode 100644 index 0000000000..54f1fea7d0 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/_config.js @@ -0,0 +1,37 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: ` + + `, + + async test({ assert, target, window, variant }) { + assert.htmlEqual( + target.innerHTML, + ` + + ` + ); + + const [select] = target.querySelectorAll('select'); + const options = target.querySelectorAll('option'); + + assert.equal(select.value, ''); + + const change = new window.Event('change'); + + // Select "UK" + options[2].selected = true; + await select.dispatchEvent(change); + + assert.equal(select.value, 'uk'); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/main.svelte b/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/main.svelte new file mode 100644 index 0000000000..57342347f5 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/binding-select-reactive-derived/main.svelte @@ -0,0 +1,21 @@ + + + From 015e744962e46a13011f8c7e3cdd4db7c9f742ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:08:12 -0500 Subject: [PATCH 57/71] Version Packages (#17668) 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.50.2 ### Patch Changes - fix: resolve `effect_update_depth_exceeded` when using `bind:value` on `` with derived state in legacy mode diff --git a/.changeset/orange-wasps-visit.md b/.changeset/orange-wasps-visit.md deleted file mode 100644 index 972ff636b8..0000000000 --- a/.changeset/orange-wasps-visit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't swallow `DOMException` when `media.play()` fails in `bind:paused` diff --git a/.changeset/tender-pugs-hide.md b/.changeset/tender-pugs-hide.md deleted file mode 100644 index 260f49614e..0000000000 --- a/.changeset/tender-pugs-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: provide proper public type for `parseCss` result diff --git a/.changeset/true-cities-retire.md b/.changeset/true-cities-retire.md deleted file mode 100644 index c1846e9267..0000000000 --- a/.changeset/true-cities-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: robustify blocker calculation diff --git a/.changeset/wild-dolls-hang.md b/.changeset/wild-dolls-hang.md deleted file mode 100644 index a7b3436d69..0000000000 --- a/.changeset/wild-dolls-hang.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reduce if block nesting diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 3fcc8edfc1..5882398fb0 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.50.2 + +### Patch Changes + +- fix: resolve `effect_update_depth_exceeded` when using `bind:value` on `