From d86cb5cc327a29e7e509ada93a3338d2118aab93 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:59:37 +0200 Subject: [PATCH 01/73] fix: skip rebase logic in non-async mode (#18040) Fixes part of #17940 (the hydration->error thing still needs a repro). Essentially in sync mode render effects are executed during traversing the effect tree, and when flushSync is called during that it can cause the timing of things getting out of sync such that you end up wanting to rebase another branch, which is never needed in sync mode. So we just skip that logic in sync mode. Another way would be to adjust flushSync such that it does appends itself to the end of the current flushing cycle if it notices processing is already ongoing. This will not work when you pass a callback function to `flushSync` though - another indicator that we should probably remove that callback argument. --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/ripe-mails-wave.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 5 ++++- .../flush-sync-each-block/Inner.svelte | 8 ++++++++ .../samples/flush-sync-each-block/_config.js | 20 +++++++++++++++++++ .../samples/flush-sync-each-block/main.svelte | 12 +++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .changeset/ripe-mails-wave.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte diff --git a/.changeset/ripe-mails-wave.md b/.changeset/ripe-mails-wave.md new file mode 100644 index 0000000000..6ccc1c724d --- /dev/null +++ b/.changeset/ripe-mails-wave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: skip rebase logic in non-async mode diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 23cc73ffc8..f98436e7bc 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -349,7 +349,9 @@ export class Batch { next_batch.#process(); } - if (!batches.has(this)) { + // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode + // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed + if (async_mode_flag && !batches.has(this)) { this.#commit(); } } @@ -791,6 +793,7 @@ export class Batch { } } +// TODO Svelte@6 think about removing the callback argument. /** * Synchronously flush any pending updates. * Returns void if no callback is provided, otherwise returns the result of calling the callback. diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte new file mode 100644 index 0000000000..6d7c982db0 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/Inner.svelte @@ -0,0 +1,8 @@ + + +{value} diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js new file mode 100644 index 0000000000..2798f533c6 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 2 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte new file mode 100644 index 0000000000..3be1202c97 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/flush-sync-each-block/main.svelte @@ -0,0 +1,12 @@ + + + + + + +{#each [count] as row} + {row} +{/each} From cd5bda00a81687df415d6b35ad1b16109f847216 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 6 Apr 2026 12:06:41 -0400 Subject: [PATCH 02/73] chore: fix changeset (#18073) prevents the changelog from getting borked up --- .changeset/great-toes-behave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/great-toes-behave.md b/.changeset/great-toes-behave.md index 26e36f70f1..5f2845e364 100644 --- a/.changeset/great-toes-behave.md +++ b/.changeset/great-toes-behave.md @@ -2,4 +2,4 @@ 'svelte': patch --- -fix: correct types for `ontoggle` on
elements +fix: correct types for `ontoggle` on `
` elements From 0395ef0df7ff12c4b633b650c5f2db512d382836 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:11:08 +0200 Subject: [PATCH 03/73] fix: unskip branches of earlier batches after commit (#18048) Fixes #17571 where the situation is the following: A derived creates a new query. That query initializes loading with true. This means the if block is marked for destruction (therefore effects inside branch are skipped), but it's not doing that yet because the query promise is pending. Then query resolves and loading is set back to false right before resolving, but it's not the same tick so `loading=false` is a separate thing. Because that later batch doesn't see any overlap with an earlier batch (the earlier batch did set loading to true but not via set but indirectly via recreating the query) it doesn't wait on it and flushes right away. Now the if block is marked as visible again but the earlier batch doesn't know that if noone unskips its branch. If we don't do that the render effect that is now dirty as part of that batch will not run. --- .changeset/petite-signs-flash.md | 5 +++ .../src/internal/client/reactivity/batch.js | 28 +++++++++++-- .../samples/async-if-block-unskip/_config.js | 30 ++++++++++++++ .../samples/async-if-block-unskip/main.svelte | 40 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 .changeset/petite-signs-flash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte diff --git a/.changeset/petite-signs-flash.md b/.changeset/petite-signs-flash.md new file mode 100644 index 0000000000..38a1c7b47c --- /dev/null +++ b/.changeset/petite-signs-flash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: unskip branches of earlier batches after commit diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index f98436e7bc..9c0832150a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -172,6 +172,12 @@ export class Batch { */ #skipped_branches = new Map(); + /** + * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing + * @type {Set} + */ + #unskipped_branches = new Set(); + is_fork = false; #decrement_queued = false; @@ -215,28 +221,31 @@ export class Batch { if (!this.#skipped_branches.has(effect)) { this.#skipped_branches.set(effect, { d: [], m: [] }); } + this.#unskipped_branches.delete(effect); } /** * Remove an effect from the #skipped_branches map and reschedule * any tracked dirty/maybe_dirty child effects * @param {Effect} effect + * @param {(e: Effect) => void} callback */ - unskip_effect(effect) { + unskip_effect(effect, callback = (e) => this.schedule(e)) { var tracked = this.#skipped_branches.get(effect); if (tracked) { this.#skipped_branches.delete(effect); for (var e of tracked.d) { set_signal_status(e, DIRTY); - this.schedule(e); + callback(e); } for (e of tracked.m) { set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); + callback(e); } } + this.#unskipped_branches.add(effect); } #process() { @@ -532,6 +541,19 @@ export class Batch { invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); } + // A batch was unskipped in a later batch -> tell prior batches to unskip it, too + if (is_earlier) { + for (const unskipped of this.#unskipped_branches) { + batch.unskip_effect(unskipped, (e) => { + if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) { + batch.schedule(e); + } else { + batch.#defer_effects([e]); + } + }); + } + } + batch.activate(); /** @type {Set} */ diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js new file mode 100644 index 0000000000..d578def483 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [load, resolve] = target.querySelectorAll('button'); + + load.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + search search search search + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte new file mode 100644 index 0000000000..36ebcd26d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-block-unskip/main.svelte @@ -0,0 +1,40 @@ + + +{query} {await promise} + +{#if !promise.loading} + {query} +{/if} + +{#if !promise.loading} + {await query} +{/if} + + + From 8966601dcd14582cd46d4fbb7c5cf1b444292255 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 6 Apr 2026 17:42:32 -0400 Subject: [PATCH 04/73] fix: handle parens in template expressions more robustly (#18075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While looking into #17954 I realised that a) our code for handling parentheses in expressions is unnecessarily convoluted and b) it doesn't handle the case where you have an opening parent outside the first comment — this fails to parse: ```svelte {(/**/ 42)} ``` This fixes it and simplifies the code a good bit. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/dull-cows-tie.md | 5 ++ .../src/compiler/phases/1-parse/acorn.js | 31 ++++++---- .../compiler/phases/1-parse/read/context.js | 20 +++--- .../phases/1-parse/read/expression.js | 40 +----------- .../src/compiler/phases/1-parse/state/tag.js | 7 +-- .../parser-modern/samples/parens/input.svelte | 1 + .../parser-modern/samples/parens/output.json | 61 +++++++++++++++++++ 7 files changed, 100 insertions(+), 65 deletions(-) create mode 100644 .changeset/dull-cows-tie.md create mode 100644 packages/svelte/tests/parser-modern/samples/parens/input.svelte create mode 100644 packages/svelte/tests/parser-modern/samples/parens/output.json diff --git a/.changeset/dull-cows-tie.md b/.changeset/dull-cows-tie.md new file mode 100644 index 0000000000..9835805dce --- /dev/null +++ b/.changeset/dull-cows-tie.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle parens in template expressions more robustly diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 77ce4a461c..797ab4cea5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -1,5 +1,6 @@ /** @import { Comment, Program } from 'estree' */ /** @import { AST } from '#compiler' */ +/** @import { Parser } from './index.js' */ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; @@ -66,26 +67,22 @@ export function parse(source, comments, typescript, is_script) { } /** + * @param {Parser} parser * @param {string} source - * @param {Comment[]} comments - * @param {boolean} typescript * @param {number} index * @returns {acorn.Expression & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }} */ -export function parse_expression_at(source, comments, typescript, index) { - const parser = typescript ? ParserWithTS : acorn.Parser; +export function parse_expression_at(parser, source, index) { + const _ = parser.ts ? ParserWithTS : acorn.Parser; - const { onComment, add_comments } = get_comment_handlers( - source, - /** @type {CommentWithLocation[]} */ (comments), - index - ); + const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index); - const ast = parser.parseExpressionAt(source, index, { + const ast = _.parseExpressionAt(source, index, { onComment, sourceType: 'module', ecmaVersion: 16, - locations: true + locations: true, + preserveParens: true }); add_comments(ast); @@ -93,6 +90,18 @@ export function parse_expression_at(source, comments, typescript, index) { return ast; } +/** + * @param {acorn.Expression} node + * @returns {acorn.Expression} + */ +export function remove_parens(node) { + return walk(node, null, { + ParenthesizedExpression(node, context) { + return context.visit(node.expression); + } + }); +} + /** * Acorn doesn't add comments to the AST by itself. This factory returns the capabilities * to add them after the fact. They are needed in order to support `svelte-ignore` comments diff --git a/packages/svelte/src/compiler/phases/1-parse/read/context.js b/packages/svelte/src/compiler/phases/1-parse/read/context.js index f90d59fa0b..24b7e2c6b0 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/context.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/context.js @@ -1,7 +1,7 @@ /** @import { Pattern } from 'estree' */ /** @import { Parser } from '../index.js' */ import { match_bracket } from '../utils/bracket.js'; -import { parse_expression_at } from '../acorn.js'; +import { parse_expression_at, remove_parens } from '../acorn.js'; import { regex_not_newline_characters } from '../../patterns.js'; import * as e from '../../../errors.js'; @@ -49,14 +49,12 @@ export default function read_pattern(parser) { space_with_newline = space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1); - const expression = /** @type {any} */ ( - parse_expression_at( - `${space_with_newline}(${pattern_string} = 1)`, - parser.root.comments, - parser.ts, - start - 1 - ) - ).left; + /** @type {any} */ + let expression = remove_parens( + parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1) + ); + + expression = expression.left; expression.typeAnnotation = read_type_annotation(parser); if (expression.typeAnnotation) { @@ -92,13 +90,13 @@ function read_type_annotation(parser) { // parameters as part of a sequence expression instead, and will then error on optional // parameters (`?:`). Therefore replace that sequence with something that will not error. parser.template.slice(parser.index).replace(/\?\s*:/g, ':'); - let expression = parse_expression_at(template, parser.root.comments, parser.ts, a); + let expression = remove_parens(parse_expression_at(parser, template, a)); // `foo: bar = baz` gets mangled — fix it if (expression.type === 'AssignmentExpression') { let b = expression.right.start; while (template[b] !== '=') b -= 1; - expression = parse_expression_at(template.slice(0, b), parser.root.comments, parser.ts, a); + expression = remove_parens(parse_expression_at(parser, template.slice(0, b), a)); } // `array as item: string, index` becomes `string, index`, which is mistaken as a sequence expression - fix that diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 5d21f85792..16d4c4e50f 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -1,6 +1,6 @@ /** @import { Expression } from 'estree' */ /** @import { Parser } from '../index.js' */ -import { parse_expression_at } from '../acorn.js'; +import { parse_expression_at, remove_parens } from '../acorn.js'; import { regex_whitespace } from '../../patterns.js'; import * as e from '../../../errors.js'; import { find_matching_bracket } from '../utils/bracket.js'; @@ -34,50 +34,16 @@ export function get_loose_identifier(parser, opening_token) { */ export default function read_expression(parser, opening_token, disallow_loose) { try { - let comment_index = parser.root.comments.length; - - const node = parse_expression_at( - parser.template, - parser.root.comments, - parser.ts, - parser.index - ); - - let num_parens = 0; - - let i = parser.root.comments.length; - while (i-- > comment_index) { - const comment = parser.root.comments[i]; - if (comment.end < node.start) { - parser.index = comment.end; - break; - } - } - - for (let i = parser.index; i < /** @type {number} */ (node.start); i += 1) { - if (parser.template[i] === '(') num_parens += 1; - } + const node = parse_expression_at(parser, parser.template, parser.index); let index = /** @type {number} */ (node.end); const last_comment = parser.root.comments.at(-1); if (last_comment && last_comment.end > index) index = last_comment.end; - while (num_parens > 0) { - const char = parser.template[index]; - - if (char === ')') { - num_parens -= 1; - } else if (!regex_whitespace.test(char)) { - e.expected_token(index, ')'); - } - - index += 1; - } - parser.index = index; - return /** @type {Expression} */ (node); + return /** @type {Expression} */ (remove_parens(node)); } catch (err) { // If we are in an each loop we need the error to be thrown in cases like // `as { y = z }` so we still throw and handle the error there diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index d9518c726f..ff153128a5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -392,12 +392,7 @@ function open(parser) { let function_expression = matched ? /** @type {ArrowFunctionExpression} */ ( - parse_expression_at( - prelude + `${params} => {}`, - parser.root.comments, - parser.ts, - params_start - ) + parse_expression_at(parser, prelude + `${params} => {}`, params_start) ) : { params: [] }; diff --git a/packages/svelte/tests/parser-modern/samples/parens/input.svelte b/packages/svelte/tests/parser-modern/samples/parens/input.svelte new file mode 100644 index 0000000000..ad4f4b7940 --- /dev/null +++ b/packages/svelte/tests/parser-modern/samples/parens/input.svelte @@ -0,0 +1 @@ +{(/**/ 42)} diff --git a/packages/svelte/tests/parser-modern/samples/parens/output.json b/packages/svelte/tests/parser-modern/samples/parens/output.json new file mode 100644 index 0000000000..7a5b4b38d8 --- /dev/null +++ b/packages/svelte/tests/parser-modern/samples/parens/output.json @@ -0,0 +1,61 @@ +{ + "css": null, + "js": [], + "start": 0, + "end": 11, + "type": "Root", + "fragment": { + "type": "Fragment", + "nodes": [ + { + "type": "ExpressionTag", + "start": 0, + "end": 11, + "expression": { + "type": "Literal", + "start": 7, + "end": 9, + "loc": { + "start": { + "line": 1, + "column": 7 + }, + "end": { + "line": 1, + "column": 9 + } + }, + "value": 42, + "raw": "42", + "leadingComments": [ + { + "type": "Block", + "value": "", + "start": 2, + "end": 6 + } + ] + } + } + ] + }, + "options": null, + "comments": [ + { + "type": "Block", + "value": "", + "start": 2, + "end": 6, + "loc": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 6 + } + } + } + ] +} From 6b653b8d17c80b16659c5238875977f0941490c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 7 Apr 2026 15:06:21 -0400 Subject: [PATCH 05/73] chore: simplify parser (#18077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit small tidy-up spurred by #17954 — rather than try-catching every `parse_expression_at` call and passing the error to `parser.acorn_error`, we can handle the error locally and get rid of that method ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../src/compiler/phases/1-parse/acorn.js | 66 ++++++++++++------- .../src/compiler/phases/1-parse/index.js | 10 --- .../compiler/phases/1-parse/read/context.js | 54 +++++++-------- .../phases/1-parse/read/expression.js | 2 +- .../compiler/phases/1-parse/read/script.js | 9 +-- 5 files changed, 69 insertions(+), 72 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 797ab4cea5..45a7c2a58c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -4,8 +4,10 @@ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; +import * as e from '../../errors.js'; -const ParserWithTS = acorn.Parser.extend(tsPlugin()); +const JSParser = acorn.Parser; +const TSParser = JSParser.extend(tsPlugin()); /** * @typedef {Comment & { @@ -21,15 +23,15 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin()); * @param {boolean} [is_script] */ export function parse(source, comments, typescript, is_script) { - const parser = typescript ? ParserWithTS : acorn.Parser; + const acorn = typescript ? TSParser : JSParser; const { onComment, add_comments } = get_comment_handlers( source, /** @type {CommentWithLocation[]} */ (comments) ); - // @ts-ignore - const parse_statement = parser.prototype.parseStatement; + // @ts-expect-error + const parse_statement = acorn.prototype.parseStatement; // If we're dealing with a + + + + + + + {#if show} + {await Promise.reject('boom')} + {/if} + {#snippet failed()} + failed + {/snippet} + \ No newline at end of file From 7be1a0247f1ea84db2e951ab27716c68de5b0650 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:49:04 +0200 Subject: [PATCH 11/73] fix: ensure proper HMR updates for dynamic components (#18079) The BranchManager in `branches.js` does create a temporary anchor when creating a new branch offscreen, and deletes it once the branch is committed. Normally this is fine, but the combination of HMR and dynamic components leads to a bug: Since `svelte-component.js` passes the temporary anchor along to the component it generates, which is the HMR wrapper, this wrapper will have an obsolete, disconnected anchor on updates, leading to the content disappearing. The fix is to add a dev-only symbol which we set on the original (then obsolete) anchor to tell about the updated anchor that should be used for HMR updates. Fixes https://github.com/sveltejs/kit/issues/14699 Fixes #17211 --- .changeset/all-masks-dig.md | 5 ++++ .../svelte/src/internal/client/constants.js | 2 ++ .../svelte/src/internal/client/dev/hmr.js | 9 ++++-- .../internal/client/dom/blocks/branches.js | 8 +++++ .../hmr-dynamic-component/Component.svelte | 1 + .../samples/hmr-dynamic-component/_config.js | 30 +++++++++++++++++++ .../samples/hmr-dynamic-component/main.svelte | 9 ++++++ 7 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .changeset/all-masks-dig.md create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte diff --git a/.changeset/all-masks-dig.md b/.changeset/all-masks-dig.md new file mode 100644 index 0000000000..ffa40655b9 --- /dev/null +++ b/.changeset/all-masks-dig.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure proper HMR updates for dynamic components diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index df96f4899b..a3ad988ba1 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -62,6 +62,8 @@ export const STATE_SYMBOL = Symbol('$state'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); +/** An anchor might change, via this symbol on the original anchor we can tell HMR about the updated anchor */ +export const HMR_ANCHOR = Symbol('hmr anchor'); /** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */ export const STALE_REACTION = new (class StaleReactionError extends Error { diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 13ee35b20d..73dba95f9b 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -1,6 +1,6 @@ /** @import { Effect, TemplateNode } from '#client' */ import { FILENAME, HMR } from '../../../constants.js'; -import { EFFECT_TRANSPARENT } from '#client/constants'; +import { EFFECT_TRANSPARENT, HMR_ANCHOR } from '#client/constants'; import { hydrate_node, hydrating } from '../dom/hydration.js'; import { block, branch, destroy_effect } from '../reactivity/effects.js'; import { set, source } from '../reactivity/sources.js'; @@ -15,10 +15,10 @@ export function hmr(fn) { const current = source(fn); /** - * @param {TemplateNode} anchor + * @param {TemplateNode} initial_anchor * @param {any} props */ - function wrapper(anchor, props) { + function wrapper(initial_anchor, props) { let component = {}; let instance = {}; @@ -26,6 +26,7 @@ export function hmr(fn) { let effect; let ran = false; + let anchor = initial_anchor; block(() => { if (component === (component = get(current))) { @@ -39,6 +40,8 @@ export function hmr(fn) { } effect = branch(() => { + anchor = /** @type {any} */ (anchor)[HMR_ANCHOR] ?? anchor; + // when the component is invalidated, replace it without transitions if (ran) set_should_intro(false); diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index a8096e0a58..33c34f58bb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -7,8 +7,10 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; +import { HMR_ANCHOR } from '../../constants.js'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; +import { DEV } from 'esm-env'; /** * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch @@ -91,6 +93,12 @@ export class BranchManager { this.#onscreen.set(key, offscreen.effect); this.#offscreen.delete(key); + if (DEV) { + // Tell hmr.js about the anchor it should use for updates, + // since the initial one will be removed + /** @type {any} */ (offscreen.fragment.lastChild)[HMR_ANCHOR] = this.anchor; + } + // remove the anchor... /** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove(); diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte new file mode 100644 index 0000000000..491b4e06ed --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte @@ -0,0 +1 @@ +component diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js new file mode 100644 index 0000000000..8adf57f2eb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js @@ -0,0 +1,30 @@ +import { flushSync } from 'svelte'; +import { HMR } from 'svelte/internal/client'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true, + hmr: true + }, + + async test({ assert, target }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, ` component`); + + // Simulate HMR swap on the child component. + const hidden = './_output/client/Component' + '.svelte.js'; + const mod = await import(/* vite-ignore */ hidden); + const hmr_data = mod.default[HMR]; + const fake_incoming = { + // Fake a new component, else HMR source's equality check will ignore the update + [HMR]: { fn: /** @param {any} args */ (...args) => hmr_data.fn(...args), current: null } + }; + hmr_data.update(fake_incoming); + flushSync(); + assert.htmlEqual(target.innerHTML, ` component`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte new file mode 100644 index 0000000000..7cb6982953 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte @@ -0,0 +1,9 @@ + + + + + From 3937ec03bdaae8d9c097f4f015560ee4b9e32cf3 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:21:47 +0200 Subject: [PATCH 12/73] fix: correctly calculate `@const` blockers (#18039) Move the calculation of blockers into the analysis phase and then only push the right thunks in the transform phase - similar to how we already do it with top level `$.run` Fixes #18024 The solution is a tiny bit brittle (not much more than the top level one we already have) and I tried to make it a bit more robust but ended up in a rabbit hole in #18032 - we can revisit that solution once all the old stuff is gone. Until then this is the most pragmatic/non-invasive change. --------- Co-authored-by: Rich Harris --- .changeset/common-flowers-listen.md | 5 ++ .../src/compiler/phases/2-analyze/types.d.ts | 8 +++ .../phases/2-analyze/visitors/ConstTag.js | 25 +++++++++ .../phases/2-analyze/visitors/Fragment.js | 2 +- .../3-transform/client/visitors/ConstTag.js | 51 +++++++------------ .../3-transform/server/visitors/ConstTag.js | 25 ++++----- .../svelte/src/compiler/types/template.d.ts | 2 + .../async-derived-const-blocker/_config.js | 9 ++++ .../async-derived-const-blocker/main.svelte | 16 ++++++ .../_expected/server/index.svelte.js | 11 +--- .../_expected/server/index.svelte.js | 28 ++++------ 11 files changed, 107 insertions(+), 75 deletions(-) create mode 100644 .changeset/common-flowers-listen.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte diff --git a/.changeset/common-flowers-listen.md b/.changeset/common-flowers-listen.md new file mode 100644 index 0000000000..8d021b8054 --- /dev/null +++ b/.changeset/common-flowers-listen.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly calculate `@const` blockers diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 9d24f9dbac..7941113a7f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -2,6 +2,7 @@ import type { Scope } from '../scope.js'; import type { ComponentAnalysis, ReactiveStatement } from '../types.js'; import type { AST, StateField, ValidatedCompileOptions } from '#compiler'; import type { ExpressionMetadata } from '../nodes.js'; +import type { Identifier } from 'estree'; export interface AnalysisState { scope: Scope; @@ -33,6 +34,13 @@ export interface AnalysisState { * Set when we're inside a `$derived(...)` expression (but not `$derived.by(...)`) or `@const` */ derived_function_depth: number; + + /** Collected info about async `{@const }` declarations */ + async_consts?: { + id: Identifier; + /** How many `@const` declarations there are (already) in this scope */ + declaration_count: number; + }; } export type Context = import('zimmerframe').Context< diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index 77ea654905..22ffc24c1e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -1,6 +1,7 @@ /** @import { AST } from '#compiler' */ /** @import { Context } from '../types' */ import * as e from '../../../errors.js'; +import * as b from '#compiler/builders'; import { validate_opening_tag } from './shared/utils.js'; /** @@ -42,4 +43,28 @@ export function ConstTag(node, context) { function_depth: context.state.function_depth + 1, derived_function_depth: context.state.function_depth + 1 }); + + const has_await = node.metadata.expression.has_await; + const blockers = [...node.metadata.expression.dependencies] + .map((dep) => dep.blocker) + .filter((b) => b !== null && b.object !== context.state.async_consts?.id); + + if (has_await || context.state.async_consts || blockers.length > 0) { + const run = (context.state.async_consts ??= { + id: context.state.analysis.root.unique('promises'), + declaration_count: 0 + }); + node.metadata.promises_id = run.id; + + const bindings = context.state.scope.get_bindings(declaration); + + // keep the counter in sync with the number of thunks pushed in ConstTag in transform + // TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust + // via something like the approach in https://github.com/sveltejs/svelte/pull/18032 + const length = run.declaration_count++; + const blocker = b.member(run.id, b.literal(length), true); + for (const binding of bindings) { + binding.blocker = blocker; + } + } } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js index 02d780dc0d..def3860175 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js @@ -6,5 +6,5 @@ * @param {Context} context */ export function Fragment(node, context) { - context.next({ ...context.state, fragment: node }); + context.next({ ...context.state, fragment: node, async_consts: undefined }); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index d2bd3c10dc..ac8f120d3d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -1,7 +1,6 @@ -/** @import { Expression, Identifier, Pattern } from 'estree' */ +/** @import { Expression, Identifier, Pattern, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ -/** @import { ExpressionMetadata } from '../../../nodes.js' */ import { dev } from '../../../../state.js'; import { extract_identifiers } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; @@ -27,13 +26,7 @@ export function ConstTag(node, context) { context.state.transform[declaration.id.name] = { read: get_value }; - add_const_declaration( - context.state, - declaration.id, - expression, - node.metadata.expression, - context.state.scope.get_bindings(declaration) - ); + add_const_declaration(context.state, declaration.id, expression, node.metadata); } else { const identifiers = extract_identifiers(declaration.id); const tmp = b.id(context.state.scope.generate('computed_const')); @@ -70,13 +63,7 @@ export function ConstTag(node, context) { expression = b.call('$.tag', expression, b.literal('[@const]')); } - add_const_declaration( - context.state, - tmp, - expression, - node.metadata.expression, - context.state.scope.get_bindings(declaration) - ); + add_const_declaration(context.state, tmp, expression, node.metadata); for (const node of identifiers) { context.state.transform[node.name] = { @@ -90,42 +77,40 @@ export function ConstTag(node, context) { * @param {ComponentContext['state']} state * @param {Identifier} id * @param {Expression} expression - * @param {ExpressionMetadata} metadata - * @param {import('#compiler').Binding[]} bindings + * @param {AST.ConstTag['metadata']} metadata */ -function add_const_declaration(state, id, expression, metadata, bindings) { +function add_const_declaration(state, id, expression, metadata) { // we need to eagerly evaluate the expression in order to hit any // 'Cannot access x before initialization' errors const after = dev ? [b.stmt(b.call('$.get', id))] : []; - const has_await = metadata.has_await; - const blockers = [...metadata.dependencies] + const blockers = [...metadata.expression.dependencies] .map((dep) => dep.blocker) .filter((b) => b !== null && b.object !== state.async_consts?.id); - if (has_await || state.async_consts || blockers.length > 0) { + if (metadata.promises_id) { const run = (state.async_consts ??= { - id: b.id(state.scope.generate('promises')), + id: metadata.promises_id, thunks: [] }); state.consts.push(b.let(id)); - const assignment = b.assignment('=', id, expression); - const body = after.length === 0 ? assignment : b.block([b.stmt(assignment), ...after]); + /** @type {Statement | undefined} */ + let promise_stmt; if (blockers.length === 1) { - run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); + promise_stmt = b.stmt(b.await(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); } else if (blockers.length > 0) { - run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers)))); + promise_stmt = b.stmt(b.await(b.call('$.wait', b.array(blockers)))); } - run.thunks.push(b.thunk(body, has_await)); - - const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true); - - for (const binding of bindings) { - binding.blocker = blocker; + // keep the number of thunks pushed in sync with ConstTag in analysis phase + const assignment = b.assignment('=', id, expression); + if (promise_stmt) { + run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true)); + } else { + run.thunks.push(b.thunk(assignment, metadata.expression.has_await)); } } else { state.consts.push(b.const(id, expression)); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js index d2ff9a10b4..87d60442f6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js @@ -1,4 +1,4 @@ -/** @import { Expression, Pattern } from 'estree' */ +/** @import { Expression, Pattern, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; @@ -12,36 +12,37 @@ export function ConstTag(node, context) { const declaration = node.declaration.declarations[0]; const id = /** @type {Pattern} */ (context.visit(declaration.id)); const init = /** @type {Expression} */ (context.visit(declaration.init)); - const has_await = node.metadata.expression.has_await; const blockers = [...node.metadata.expression.dependencies] .map((dep) => dep.blocker) .filter((b) => b !== null && b.object !== context.state.async_consts?.id); - if (has_await || context.state.async_consts || blockers.length > 0) { + if (node.metadata.promises_id) { const run = (context.state.async_consts ??= { - id: b.id(context.state.scope.generate('promises')), + id: node.metadata.promises_id, thunks: [] }); const identifiers = extract_identifiers(declaration.id); - const bindings = context.state.scope.get_bindings(declaration); for (const identifier of identifiers) { context.state.init.push(b.let(identifier.name)); } + /** @type {Statement | undefined} */ + let promise_stmt; + if (blockers.length === 1) { - run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0]))); + promise_stmt = b.stmt(b.await(/** @type {Expression} */ (blockers[0]))); } else if (blockers.length > 0) { - run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers)))); + promise_stmt = b.stmt(b.await(b.call('Promise.all', b.array(blockers)))); } + // keep the number of thunks pushed in sync with ConstTag in analysis phase const assignment = b.assignment('=', id, init); - run.thunks.push(b.thunk(b.block([b.stmt(assignment)]), has_await)); - - const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true); - for (const binding of bindings) { - binding.blocker = blocker; + if (promise_stmt) { + run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true)); + } else { + run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await)); } } else { context.state.init.push(b.const(id, init)); diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 3c1e3e772c..2964e46761 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -155,6 +155,8 @@ export namespace AST { /** @internal */ metadata: { expression: ExpressionMetadata; + /** If this const tag contains an await expression, or needs to wait on other async, this is set */ + promises_id?: Identifier; }; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js new file mode 100644 index 0000000000..afa737f36c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/_config.js @@ -0,0 +1,9 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

data

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte new file mode 100644 index 0000000000..a154859683 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-const-blocker/main.svelte @@ -0,0 +1,16 @@ + + +{#if d} + {@const {data, hasData} = d} + + {#if hasData} +

{data}

+ {:else if showFetchCta} +

Fetch now

+ {:else} +

No data

+ {/if} +{/if} 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 5f3cfa6ca6..c0cb999e85 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 @@ -7,16 +7,7 @@ export default function Async_const($$renderer) { let a; let b; - - var promises = $$renderer.run([ - async () => { - a = (await $.save(1))(); - }, - - () => { - b = a + 1; - } - ]); + var promises = $$renderer.run([async () => a = (await $.save(1))(), () => b = a + 1]); $$renderer.push(`

`); $$renderer.async([promises[1]], ($$renderer) => $$renderer.push(() => $.escape(b))); 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 3a53475944..0cebc412ef 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 @@ -28,25 +28,15 @@ export default function Async_in_derived($$renderer, $$props) { let no2; var promises = $$renderer.run([ - async () => { - yes1 = (await $.save(1))(); - }, - - async () => { - yes2 = foo((await $.save(1))()); - }, - - () => { - no1 = (async () => { - return await 1; - })(); - }, - - () => { - no2 = (async () => { - return await 1; - })(); - } + async () => yes1 = (await $.save(1))(), + async () => yes2 = foo((await $.save(1))()), + () => no1 = (async () => { + return await 1; + })(), + + () => no2 = (async () => { + return await 1; + })() ]); } else { $$renderer.push(''); From 0e9e76f29262b5f64ac7a5d4db37ec83c9181634 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Apr 2026 16:45:07 -0400 Subject: [PATCH 13/73] fix: freeze deriveds once their containing effects are destroyed (#17921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per https://github.com/sveltejs/svelte/pull/17862#issuecomment-4049752548, this freezes the value of a derived if it was created inside a parent effect that is now destroyed. This prevents the sort of bug where a derived reads `foo.bar` even though `foo` is now `undefined`. If the derived is dirty, a warning will be printed. This PR also gets rid of some weirdness around `derived.parent` — it can only ever be an `Effect | null`, and there's no need for `get_derived_parent_effect`. Blocked on https://github.com/sveltejs/kit/pull/15533 and a follow-up that switches remote functions to use `$effect.root` (since this effectively undoes #17171), hence draft. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: github-actions[bot] Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/silly-phones-follow.md | 5 +++ .../.generated/client-warnings.md | 8 +++++ .../messages/client-warnings/warnings.md | 6 ++++ .../internal/client/reactivity/deriveds.js | 35 ++++++------------- .../src/internal/client/reactivity/types.d.ts | 4 +-- .../svelte/src/internal/client/warnings.js | 11 ++++++ packages/svelte/tests/signals/test.ts | 18 +++++++--- 7 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 .changeset/silly-phones-follow.md diff --git a/.changeset/silly-phones-follow.md b/.changeset/silly-phones-follow.md new file mode 100644 index 0000000000..c8671c6abe --- /dev/null +++ b/.changeset/silly-phones-follow.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: freeze deriveds once their containing effects are destroyed diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index 7daf808d61..73c97c253c 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -134,6 +134,14 @@ When logging a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/R The easiest way to log a value as it changes over time is to use the [`$inspect`](/docs/svelte/$inspect) rune. Alternatively, to log things on a one-off basis (for example, inside an event handler) you can use [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) to take a snapshot of the current value. +### derived_inert + +``` +Reading a derived belonging to a now-destroyed effect may result in stale values +``` + +A `$derived` value created inside an effect will stop updating when the effect is destroyed. You should create the `$derived` outside the effect, or inside an `$effect.root`. + ### event_handler_invalid ``` diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 6998173a99..58d00f3933 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -120,6 +120,12 @@ When logging a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/R The easiest way to log a value as it changes over time is to use the [`$inspect`](/docs/svelte/$inspect) rune. Alternatively, to log things on a one-off basis (for example, inside an event handler) you can use [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) to take a snapshot of the current value. +## derived_inert + +> Reading a derived belonging to a now-destroyed effect may result in stale values + +A `$derived` value created inside an effect will stop updating when the effect is destroyed. You should create the `$derived` outside the effect, or inside an `$effect.root`. + ## event_handler_invalid > %handler% should be a function. Did you mean to %suggestion%? diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 77acb23516..12dd7bf672 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -12,7 +12,8 @@ import { WAS_MARKED, DESTROYED, CLEAN, - REACTION_RAN + REACTION_RAN, + INERT } from '#client/constants'; import { active_reaction, @@ -67,10 +68,6 @@ export const recent_async_deriveds = new Set(); /*#__NO_SIDE_EFFECTS__*/ export function derived(fn) { var flags = DERIVED | DIRTY; - var parent_derived = - active_reaction !== null && (active_reaction.f & DERIVED) !== 0 - ? /** @type {Derived} */ (active_reaction) - : null; if (active_effect !== null) { // Since deriveds are evaluated lazily, any effects created inside them are @@ -90,7 +87,7 @@ export function derived(fn) { rv: 0, v: /** @type {V} */ (UNINITIALIZED), wv: 0, - parent: parent_derived ?? active_effect, + parent: active_effect, ac: null }; @@ -320,23 +317,6 @@ export function destroy_derived_effects(derived) { */ let stack = []; -/** - * @param {Derived} derived - * @returns {Effect | null} - */ -function get_derived_parent_effect(derived) { - var parent = derived.parent; - while (parent !== null) { - if ((parent.f & DERIVED) === 0) { - // The original parent effect might've been destroyed but the derived - // is used elsewhere now - do not return the destroyed effect in that case - return (parent.f & DESTROYED) === 0 ? /** @type {Effect} */ (parent) : null; - } - parent = parent.parent; - } - return null; -} - /** * @template T * @param {Derived} derived @@ -345,8 +325,15 @@ function get_derived_parent_effect(derived) { export function execute_derived(derived) { var value; var prev_active_effect = active_effect; + var parent = derived.parent; + + if (!is_destroying_effect && parent !== null && (parent.f & (DESTROYED | INERT)) !== 0) { + w.derived_inert(); + + return derived.v; + } - set_active_effect(get_derived_parent_effect(derived)); + set_active_effect(parent); if (DEV) { let prev_eager_effects = eager_effects; diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 8477917991..0ee8570c3d 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -57,8 +57,8 @@ export interface Derived extends Value, Reaction { fn: () => V; /** Effects created inside this signal. Used to destroy those effects when the derived reruns or is cleaned up */ effects: null | Effect[]; - /** Parent effect or derived */ - parent: Effect | Derived | null; + /** Parent effect */ + parent: Effect | null; } export interface EffectNodes { diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index a9a50c57d6..f4e605ac96 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -74,6 +74,17 @@ export function console_log_state(method) { } } +/** + * Reading a derived belonging to a now-destroyed effect may result in stale values + */ +export function derived_inert() { + if (DEV) { + console.warn(`%c[svelte] derived_inert\n%cReading a derived belonging to a now-destroyed effect may result in stale values\nhttps://svelte.dev/e/derived_inert`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/derived_inert`); + } +} + /** * %handler% should be a function. Did you mean to %suggestion%? * @param {string} handler diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 5486ccdb45..927ce2e665 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1391,8 +1391,12 @@ describe('signals', () => { }; }); - test('derived whose original parent effect has been destroyed keeps updating', () => { + test('derived whose original parent effect has been destroyed no longer updates', () => { return () => { + const warn = console.warn; + const warnings: string[] = []; + console.warn = (...args) => warnings.push(...args); + let count: Source; let double: Derived; const destroy = effect_root(() => { @@ -1404,17 +1408,23 @@ describe('signals', () => { flushSync(); assert.equal($.get(double!), 0); - - destroy(); flushSync(); set(count!, 1); flushSync(); assert.equal($.get(double!), 2); + destroy(); + + assert.equal($.get(double!), 2); + assert.equal(warnings.length, 0); // value is unchanged, no warning yet + set(count!, 2); flushSync(); - assert.equal($.get(double!), 4); + assert.equal($.get(double!), 2); + assert.ok(warnings.some((str) => str.includes('derived_inert'))); + + console.warn = warn; }; }); From 15588f5fbfe736f65e189e56047ee08678f5509f Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:05:26 +0200 Subject: [PATCH 14/73] fix: avoid false positives for reactivity loss warning (#18088) Avoids two categories of false positives for reactivity loss warning: 1. if you have synchronously read signals already as part of invoking the async_derived function, then it shouldn't warn when these signals are read after an await if we know they haven't changed (we check the write version for that) 2. `track_reactivity_loss` kept the `reactivity_loss_tracker` around indefinitely, both when invoking the async operation as well as when it's finished. The former is buggy because while the async operation happens unrelated reads as part of other reactivity work can happen, the latter is buggy because if it's the last in a chain of awaits it's kept around until the next async work starts. Fixes https://github.com/sveltejs/kit/issues/15654 --------- Co-authored-by: Rich Harris Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .changeset/twenty-snakes-design.md | 5 +++ .../src/internal/client/reactivity/async.js | 18 +++++++++ .../internal/client/reactivity/deriveds.js | 37 ++++++++++++++----- .../svelte/src/internal/client/runtime.js | 7 ++-- .../_config.js | 27 ++++++++++++++ .../main.svelte | 27 ++++++++++++++ .../_config.js | 2 +- .../_config.js | 2 +- .../_config.js | 21 +++++++++++ .../main.svelte | 16 ++++++++ .../_config.js | 19 ++++++++++ .../main.svelte | 14 +++++++ .../_config.js | 19 ++++++++++ .../main.svelte | 14 +++++++ 14 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 .changeset/twenty-snakes-design.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte diff --git a/.changeset/twenty-snakes-design.md b/.changeset/twenty-snakes-design.md new file mode 100644 index 0000000000..ec1ac591f0 --- /dev/null +++ b/.changeset/twenty-snakes-design.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid false positives for reactivity loss warning diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 18e4f71c88..6aea790c36 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -164,10 +164,26 @@ export async function save(promise) { */ export async function track_reactivity_loss(promise) { var previous_async_effect = reactivity_loss_tracker; + // Ensure that unrelated reads after an async operation is kicked off don't cause false positives + queueMicrotask(() => { + if (reactivity_loss_tracker === previous_async_effect) { + set_reactivity_loss_tracker(null); + } + }); + var value = await promise; return () => { set_reactivity_loss_tracker(previous_async_effect); + // While this can result in false negatives it also guards against the more important + // false positives that would occur if this is the last in a chain of async operations, + // and the reactivity_loss_tracker would then stay around until the next async operation happens. + queueMicrotask(() => { + if (reactivity_loss_tracker === previous_async_effect) { + set_reactivity_loss_tracker(null); + } + }); + return value; }; } @@ -206,7 +222,9 @@ export async function* for_await_track_reactivity_loss(iterable) { normal_completion = true; break; } + var prev = reactivity_loss_tracker; yield value; + set_reactivity_loss_tracker(prev); } } finally { // If the iterator had an abrupt completion and `return` is defined on the iterator, call it and return the value diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 12dd7bf672..5af51449ad 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -1,4 +1,4 @@ -/** @import { Derived, Effect, Source } from '#client' */ +/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ /** @import { Batch } from './batch.js'; */ /** @import { Boundary } from '../dom/blocks/boundary.js'; */ import { DEV } from 'esm-env'; @@ -24,7 +24,9 @@ import { push_reaction_value, is_destroying_effect, update_effect, - remove_reactions + remove_reactions, + skipped_deps, + new_deps } from '../runtime.js'; import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; @@ -49,11 +51,11 @@ import { set_signal_status, update_derived_status } from './status.js'; /** * This allows us to track 'reactivity loss' that occurs when signals * are read after a non-context-restoring `await`. Dev-only - * @type {{ effect: Effect, warned: boolean } | null} + * @type {{ effect: Effect, effect_deps: Set, warned: boolean } | null} */ export let reactivity_loss_tracker = null; -/** @param {{ effect: Effect, warned: boolean } | null} v */ +/** @param {{ effect: Effect, effect_deps: Set, warned: boolean } | null} v */ export function set_reactivity_loss_tracker(v) { reactivity_loss_tracker = v; } @@ -125,15 +127,12 @@ export function async_derived(fn, label, location) { var deferreds = new Map(); async_effect(() => { + var effect = /** @type {Effect} */ (active_effect); + if (DEV) { - reactivity_loss_tracker = { - effect: /** @type {Effect} */ (active_effect), - warned: false - }; + reactivity_loss_tracker = { effect, effect_deps: new Set(), warned: false }; } - var effect = /** @type {Effect} */ (active_effect); - /** @type {ReturnType>} */ var d = deferred(); promise = d.promise; @@ -149,6 +148,24 @@ export function async_derived(fn, label, location) { } if (DEV) { + if (reactivity_loss_tracker) { + // Reused deps from previous run (indices 0 to skipped_deps-1) + // We deliberately only track direct dependencies of the async expression to encourage + // dependencies being directly visible at the point of the expression + if (effect.deps !== null) { + for (let i = 0; i < skipped_deps; i += 1) { + reactivity_loss_tracker.effect_deps.add(effect.deps[i]); + } + } + + // New deps discovered this run + if (new_deps !== null) { + for (let i = 0; i < new_deps.length; i += 1) { + reactivity_loss_tracker.effect_deps.add(new_deps[i]); + } + } + } + reactivity_loss_tracker = null; } diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index d9578142eb..033afe98dc 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -111,9 +111,9 @@ export function push_reaction_value(value) { * and until a new dependency is accessed — we track this via `skipped_deps` * @type {null | Value[]} */ -let new_deps = null; +export let new_deps = null; -let skipped_deps = 0; +export let skipped_deps = 0; /** * Tracks writes that the effect it's executed in doesn't listen to yet, @@ -580,7 +580,8 @@ export function get(signal) { !untracking && reactivity_loss_tracker && !reactivity_loss_tracker.warned && - (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 + (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 && + !reactivity_loss_tracker.effect_deps.has(signal) ) { reactivity_loss_tracker.warned = true; diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js new file mode 100644 index 0000000000..2bec9e93a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, warnings }) { + await new Promise((r) => setTimeout(r, 25)); + + const [count] = target.querySelectorAll('button'); + + count.click(); + await new Promise((r) => setTimeout(r, 25)); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `other`. This happens when state is read in an async function after an earlier `await`' + }, + { + log: 'Detected reactivity loss when reading `other`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte new file mode 100644 index 0000000000..d3bcc199a4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-async-after-sync/main.svelte @@ -0,0 +1,27 @@ + + + + +{await get()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js index 3429380800..7fb54d763c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-break-return/_config.js @@ -14,7 +14,7 @@ export default test({ assert.deepEqual(normalise_trace_logs(warnings), [ { - log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' } ]); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js index a5dd7fa28a..ddc9cef27d 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js @@ -15,7 +15,7 @@ export default test({ assert.deepEqual(normalise_trace_logs(warnings), [ { - log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' } ]); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js new file mode 100644 index 0000000000..516fa59a97 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, warnings }) { + await tick(); + + const [x, y] = target.querySelectorAll('button'); + + y.click(); + await tick(); + x.click(); + await new Promise((r) => setTimeout(r, 15)); + + assert.equal(warnings.length, 0); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte new file mode 100644 index 0000000000..9af5206705 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-1/main.svelte @@ -0,0 +1,16 @@ + + +{x} {await foo(y)} + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js new file mode 100644 index 0000000000..6c774609e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, warnings }) { + await tick(); + + const [count] = target.querySelectorAll('button'); + + count.click(); + await tick(); + + assert.equal(warnings.length, 0); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte new file mode 100644 index 0000000000..ea7e9dcc9b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-2/main.svelte @@ -0,0 +1,14 @@ + + +{await delay(count)} + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js new file mode 100644 index 0000000000..c9c02336bc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, warnings }) { + await new Promise((r) => setTimeout(r, 5)); + + const [count] = target.querySelectorAll('button'); + + count.click(); + await tick(); + + assert.equal(warnings.length, 0); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte new file mode 100644 index 0000000000..a016655b7c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-3/main.svelte @@ -0,0 +1,14 @@ + + + +{await get()} From 4a50e8ea3b7db1d8cd752b825032e4ce2878524b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:22:42 -0400 Subject: [PATCH 15/73] Version Packages (#18085) 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.55.3 ### Patch Changes - fix: ensure proper HMR updates for dynamic components ([#18079](https://github.com/sveltejs/svelte/pull/18079)) - fix: correctly calculate `@const` blockers ([#18039](https://github.com/sveltejs/svelte/pull/18039)) - fix: freeze deriveds once their containing effects are destroyed ([#17921](https://github.com/sveltejs/svelte/pull/17921)) - fix: defer error boundary rendering in forks ([#18076](https://github.com/sveltejs/svelte/pull/18076)) - fix: avoid false positives for reactivity loss warning ([#18088](https://github.com/sveltejs/svelte/pull/18088)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/all-masks-dig.md | 5 ----- .changeset/common-flowers-listen.md | 5 ----- .changeset/silly-phones-follow.md | 5 ----- .changeset/slimy-pears-throw.md | 5 ----- .changeset/twenty-snakes-design.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 8 files changed, 16 insertions(+), 27 deletions(-) delete mode 100644 .changeset/all-masks-dig.md delete mode 100644 .changeset/common-flowers-listen.md delete mode 100644 .changeset/silly-phones-follow.md delete mode 100644 .changeset/slimy-pears-throw.md delete mode 100644 .changeset/twenty-snakes-design.md diff --git a/.changeset/all-masks-dig.md b/.changeset/all-masks-dig.md deleted file mode 100644 index ffa40655b9..0000000000 --- a/.changeset/all-masks-dig.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure proper HMR updates for dynamic components diff --git a/.changeset/common-flowers-listen.md b/.changeset/common-flowers-listen.md deleted file mode 100644 index 8d021b8054..0000000000 --- a/.changeset/common-flowers-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: correctly calculate `@const` blockers diff --git a/.changeset/silly-phones-follow.md b/.changeset/silly-phones-follow.md deleted file mode 100644 index c8671c6abe..0000000000 --- a/.changeset/silly-phones-follow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: freeze deriveds once their containing effects are destroyed diff --git a/.changeset/slimy-pears-throw.md b/.changeset/slimy-pears-throw.md deleted file mode 100644 index c9954586e5..0000000000 --- a/.changeset/slimy-pears-throw.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: defer error boundary rendering in forks diff --git a/.changeset/twenty-snakes-design.md b/.changeset/twenty-snakes-design.md deleted file mode 100644 index ec1ac591f0..0000000000 --- a/.changeset/twenty-snakes-design.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid false positives for reactivity loss warning diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 0853fab898..0e804235fc 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,19 @@ # svelte +## 5.55.3 + +### Patch Changes + +- fix: ensure proper HMR updates for dynamic components ([#18079](https://github.com/sveltejs/svelte/pull/18079)) + +- fix: correctly calculate `@const` blockers ([#18039](https://github.com/sveltejs/svelte/pull/18039)) + +- fix: freeze deriveds once their containing effects are destroyed ([#17921](https://github.com/sveltejs/svelte/pull/17921)) + +- fix: defer error boundary rendering in forks ([#18076](https://github.com/sveltejs/svelte/pull/18076)) + +- fix: avoid false positives for reactivity loss warning ([#18088](https://github.com/sveltejs/svelte/pull/18088)) + ## 5.55.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index a6c07ec75e..d06c3e3941 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.55.2", + "version": "5.55.3", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index dbca16d6a2..02695cf663 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.55.2'; +export const VERSION = '5.55.3'; export const PUBLIC_VERSION = '5'; From 273f1a85a4dbe2937f2d97afa2511e828eb8ebba Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:33:02 +0200 Subject: [PATCH 16/73] fix: keep flushing new eager effects (#18102) The previous code "swallowed" new additions to the array of eager effects that happened while flushing since `eager_flush` did not clear the array before running, only afterwards. Now it clears the beforehand, causing newly added eager effects to run, too. An example where this can happen is `$state.eager` and `$effect.pending` in combination: first `$state.eager` is flushed, then due to `flushSync` the `queue_micro_task` inside `boundary.js` that flushes `$effect.pending` is triggered synchronously, adding new entries to the `eager_versions` array. If they're only cleared at the end of `eager_flush`, new entries are swallowed. Related to #18095 (but not fixing it yet) --- .changeset/sweet-boxes-unite.md | 5 +++ .../src/internal/client/reactivity/batch.js | 14 ++++---- .../async-effect-pending-eager/_config.js | 35 +++++++++++++++++++ .../async-effect-pending-eager/main.svelte | 27 ++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 .changeset/sweet-boxes-unite.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte diff --git a/.changeset/sweet-boxes-unite.md b/.changeset/sweet-boxes-unite.md new file mode 100644 index 0000000000..f58ca436be --- /dev/null +++ b/.changeset/sweet-boxes-unite.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep flushing new eager effects diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 68aeb66f93..82be1d1e8d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1074,15 +1074,13 @@ export function schedule_effect(effect) { let eager_versions = []; function eager_flush() { - try { - flushSync(() => { - for (const version of eager_versions) { - update(version); - } - }); - } finally { + flushSync(() => { + const eager = eager_versions; eager_versions = []; - } + for (const version of eager) { + update(version); + } + }); } /** diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js new file mode 100644 index 0000000000..b37b3c9228 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/_config.js @@ -0,0 +1,35 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 1 +

pending: 1

+

loading...

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

pending: 0

+

1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte new file mode 100644 index 0000000000..535968a46a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-eager/main.svelte @@ -0,0 +1,27 @@ + + + + + +{$state.eager(value)} + +{#if 1} +

pending: {$effect.pending()}

+ + {@const tmp = await delayed(value)} + {#if $effect.pending() > 0} +

loading...

+ {:else} +

{tmp}

+ {/if} +{/if} From 0ed8c282f96960f52eaf077ffbe6e53c181b3774 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:09:26 +0200 Subject: [PATCH 17/73] fix: reset context after waiting on blockers of `@const` expressions (#18100) Regression from #18039 - we need to have each await expression (and waiting on blockers is one) in its own entry of `(renderer.)run`. Else context is not restored correctly and if the synchronous expression afterwards requires it stuff breaks. Fixes #18098 --- .changeset/soft-moons-wear.md | 5 +++++ .../src/compiler/phases/2-analyze/types.d.ts | 2 +- .../phases/2-analyze/visitors/ConstTag.js | 3 ++- .../3-transform/client/visitors/ConstTag.js | 13 +++--------- .../3-transform/server/visitors/ConstTag.js | 13 +++--------- .../_config.js | 11 ++++++++++ .../main.svelte | 11 ++++++++++ .../_expected/client/index.svelte.js | 21 ++++++++++++++++++- .../_expected/server/index.svelte.js | 11 ++++++++++ .../samples/async-in-derived/index.svelte | 4 ++++ 10 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 .changeset/soft-moons-wear.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte diff --git a/.changeset/soft-moons-wear.md b/.changeset/soft-moons-wear.md new file mode 100644 index 0000000000..eeb2b14b8c --- /dev/null +++ b/.changeset/soft-moons-wear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reset context after waiting on blockers of `@const` expressions diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 7941113a7f..354f1c0856 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -38,7 +38,7 @@ export interface AnalysisState { /** Collected info about async `{@const }` declarations */ async_consts?: { id: Identifier; - /** How many `@const` declarations there are (already) in this scope */ + /** How many `$.run(...)` entries are already allocated in this scope */ declaration_count: number; }; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index 22ffc24c1e..4f07249a39 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -61,7 +61,8 @@ export function ConstTag(node, context) { // keep the counter in sync with the number of thunks pushed in ConstTag in transform // TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust // via something like the approach in https://github.com/sveltejs/svelte/pull/18032 - const length = run.declaration_count++; + const length = run.declaration_count + (blockers.length > 0 ? 1 : 0); + run.declaration_count += blockers.length > 0 ? 2 : 1; const blocker = b.member(run.id, b.literal(length), true); for (const binding of bindings) { binding.blocker = blocker; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index ac8f120d3d..bf559cd24d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -96,22 +96,15 @@ function add_const_declaration(state, id, expression, metadata) { state.consts.push(b.let(id)); - /** @type {Statement | undefined} */ - let promise_stmt; - if (blockers.length === 1) { - promise_stmt = b.stmt(b.await(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); + run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); } else if (blockers.length > 0) { - promise_stmt = b.stmt(b.await(b.call('$.wait', b.array(blockers)))); + run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers)))); } // keep the number of thunks pushed in sync with ConstTag in analysis phase const assignment = b.assignment('=', id, expression); - if (promise_stmt) { - run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true)); - } else { - run.thunks.push(b.thunk(assignment, metadata.expression.has_await)); - } + run.thunks.push(b.thunk(assignment, metadata.expression.has_await)); } else { state.consts.push(b.const(id, expression)); state.consts.push(...after); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js index 87d60442f6..9420bdd6d2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js @@ -28,22 +28,15 @@ export function ConstTag(node, context) { context.state.init.push(b.let(identifier.name)); } - /** @type {Statement | undefined} */ - let promise_stmt; - if (blockers.length === 1) { - promise_stmt = b.stmt(b.await(/** @type {Expression} */ (blockers[0]))); + run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0]))); } else if (blockers.length > 0) { - promise_stmt = b.stmt(b.await(b.call('Promise.all', b.array(blockers)))); + run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers)))); } // keep the number of thunks pushed in sync with ConstTag in analysis phase const assignment = b.assignment('=', id, init); - if (promise_stmt) { - run.thunks.push(b.thunk(b.block([promise_stmt, b.stmt(assignment)]), true)); - } else { - run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await)); - } + run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await)); } else { context.state.init.push(b.const(id, init)); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js new file mode 100644 index 0000000000..6c202c1ed6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/_config.js @@ -0,0 +1,11 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that context is restored after await (const has to wait on a blocker), so that getContext etc work correctly +export default test({ + mode: ['hydrate', 'async-server', 'client'], + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'hi'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte new file mode 100644 index 0000000000..be2d0dc7be --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-context-after-await-const/main.svelte @@ -0,0 +1,11 @@ + + +{#if true} + {@const foo = bar} + {foo} +{/if} diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js index 4f06d9ddbf..79b8ad0040 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js @@ -2,6 +2,8 @@ import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; +var root = $.from_html(` `, 1); + export default function Async_in_derived($$anchor, $$props) { $.push($$props, true); @@ -21,7 +23,7 @@ export default function Async_in_derived($$anchor, $$props) { } ]); - var fragment = $.comment(); + var fragment = root(); var node = $.first_child(fragment); { @@ -49,6 +51,23 @@ export default function Async_in_derived($$anchor, $$props) { }); } + var node_1 = $.sibling(node, 2); + + { + var consequent_1 = ($$anchor) => { + let x; + + var promises_1 = $.run([ + () => $$promises[2].promise, + () => x = $.derived(() => $.get(no2)) + ]); + }; + + $.if(node_1, ($$render) => { + if (true) $$render(consequent_1); + }); + } + $.append($$anchor, fragment); $.pop(); } \ No newline at end of file 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 0cebc412ef..651a92f9a8 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 @@ -42,6 +42,17 @@ export default function Async_in_derived($$renderer, $$props) { $$renderer.push(''); } + $$renderer.push(` `); + + if (true) { + $$renderer.push(''); + + let x; + var promises_1 = $$renderer.run([() => $$promises[2], () => x = no2()]); + } else { + $$renderer.push(''); + } + $$renderer.push(``); }); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte b/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte index bda88fd3ae..b105bef189 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/index.svelte @@ -19,3 +19,7 @@ return await 1; })()} {/if} + +{#if true} + {@const x = no2} +{/if} From 671fc2ea11b56f050f37f7e03564fb070bc8abea Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:47:56 +0200 Subject: [PATCH 18/73] fix: never mark a child effect root as inert (#18111) A nested `$effect.root` was marked `INERT` during `pause_children`, which caused it to stay in that state indefinetly after the rest of the parent tree was destroyed. Consequently deriveds inside no longer update and cause warnings. This fixes it by not marking nested `$effect.root`s as inert, just like nested `$effect.root`s are not destryoed and instead become a new root. Fixes #18097 --- .changeset/fresh-chicken-itch.md | 5 ++++ .../src/internal/client/reactivity/effects.js | 26 ++++++++++++------- .../samples/effect-root-6/Child.svelte | 14 ++++++++++ .../samples/effect-root-6/_config.js | 14 ++++++++++ .../samples/effect-root-6/main.svelte | 15 +++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .changeset/fresh-chicken-itch.md create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte diff --git a/.changeset/fresh-chicken-itch.md b/.changeset/fresh-chicken-itch.md new file mode 100644 index 0000000000..95120e1591 --- /dev/null +++ b/.changeset/fresh-chicken-itch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: never mark a child effect root as inert diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index ea8a4b645e..0fad074e6f 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -654,16 +654,22 @@ function pause_children(effect, transitions, local) { while (child !== null) { var sibling = child.next; - var transparent = - (child.f & EFFECT_TRANSPARENT) !== 0 || - // If this is a branch effect without a block effect parent, - // it means the parent block effect was pruned. In that case, - // transparency information was transferred to the branch effect. - ((child.f & BRANCH_EFFECT) !== 0 && (effect.f & BLOCK_EFFECT) !== 0); - // TODO we don't need to call pause_children recursively with a linked list in place - // it's slightly more involved though as we have to account for `transparent` changing - // through the tree. - pause_children(child, transitions, transparent ? local : false); + + // If this child is a root effect, then it will become an independent root when its parent + // is destroyed, it should therefore not become inert nor partake in transitions. + if ((child.f & ROOT_EFFECT) === 0) { + var transparent = + (child.f & EFFECT_TRANSPARENT) !== 0 || + // If this is a branch effect without a block effect parent, + // it means the parent block effect was pruned. In that case, + // transparency information was transferred to the branch effect. + ((child.f & BRANCH_EFFECT) !== 0 && (effect.f & BLOCK_EFFECT) !== 0); + // TODO we don't need to call pause_children recursively with a linked list in place + // it's slightly more involved though as we have to account for `transparent` changing + // through the tree. + pause_children(child, transitions, transparent ? local : false); + } + child = sibling; } } diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte b/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte new file mode 100644 index 0000000000..110ad82b0d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/Child.svelte @@ -0,0 +1,14 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js new file mode 100644 index 0000000000..e4077c6c27 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/_config.js @@ -0,0 +1,14 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// Test that $effect.root continues to be operational after its parent effect has been destroyed +export default test({ + test({ assert, target, logs }) { + const [hide, increment] = target.querySelectorAll('button'); + + hide.click(); + flushSync(); + increment.click(); + assert.deepEqual(logs, ['count', 1, 'double', 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte new file mode 100644 index 0000000000..254cddeb5b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-root-6/main.svelte @@ -0,0 +1,15 @@ + + + + +{#if show} + +{/if} From 7fddfbdbbde8813ee107d56f70f5ea6c3d3abbc3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:25:04 +0200 Subject: [PATCH 19/73] Version Packages (#18105) 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.55.4 ### Patch Changes - fix: never mark a child effect root as inert ([#18111](https://github.com/sveltejs/svelte/pull/18111)) - fix: reset context after waiting on blockers of `@const` expressions ([#18100](https://github.com/sveltejs/svelte/pull/18100)) - fix: keep flushing new eager effects ([#18102](https://github.com/sveltejs/svelte/pull/18102)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/fresh-chicken-itch.md | 5 ----- .changeset/soft-moons-wear.md | 5 ----- .changeset/sweet-boxes-unite.md | 5 ----- packages/svelte/CHANGELOG.md | 10 ++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 .changeset/fresh-chicken-itch.md delete mode 100644 .changeset/soft-moons-wear.md delete mode 100644 .changeset/sweet-boxes-unite.md diff --git a/.changeset/fresh-chicken-itch.md b/.changeset/fresh-chicken-itch.md deleted file mode 100644 index 95120e1591..0000000000 --- a/.changeset/fresh-chicken-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: never mark a child effect root as inert diff --git a/.changeset/soft-moons-wear.md b/.changeset/soft-moons-wear.md deleted file mode 100644 index eeb2b14b8c..0000000000 --- a/.changeset/soft-moons-wear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reset context after waiting on blockers of `@const` expressions diff --git a/.changeset/sweet-boxes-unite.md b/.changeset/sweet-boxes-unite.md deleted file mode 100644 index f58ca436be..0000000000 --- a/.changeset/sweet-boxes-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: keep flushing new eager effects diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 0e804235fc..7aa8f9818e 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # svelte +## 5.55.4 + +### Patch Changes + +- fix: never mark a child effect root as inert ([#18111](https://github.com/sveltejs/svelte/pull/18111)) + +- fix: reset context after waiting on blockers of `@const` expressions ([#18100](https://github.com/sveltejs/svelte/pull/18100)) + +- fix: keep flushing new eager effects ([#18102](https://github.com/sveltejs/svelte/pull/18102)) + ## 5.55.3 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d06c3e3941..12aa895ecf 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.55.3", + "version": "5.55.4", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 02695cf663..cb8d3f76ab 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.55.3'; +export const VERSION = '5.55.4'; export const PUBLIC_VERSION = '5'; From 6a149666c1772b443c0db9385ee7521275b27487 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:34:30 +0200 Subject: [PATCH 20/73] chore: enhance download script to ask for folder (#18112) If project that is downloaded is a project that can't be transfered into the playground (because it relies on SvelteKit stuff), instead of failing it now asks if you want to put it into another folder within the playgrounds folder --- playgrounds/sandbox/scripts/download.js | 94 ++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/playgrounds/sandbox/scripts/download.js b/playgrounds/sandbox/scripts/download.js index 538b4956fe..5b915c8abd 100644 --- a/playgrounds/sandbox/scripts/download.js +++ b/playgrounds/sandbox/scripts/download.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { parseArgs } from 'node:util'; import { execSync } from 'node:child_process'; +import readline from 'node:readline/promises'; import { chromium } from 'playwright'; const { values, positionals } = parseArgs({ @@ -546,9 +547,9 @@ function convert_vite_project(repo_dir) { /** * Process a local or cloned directory * @param {string} dir_path - * @returns {Array<{name: string, contents: string}>} + * @returns {Promise | null>} */ -function process_directory(dir_path) { +async function process_directory(dir_path) { const all_files = get_all_files(dir_path); const project_info = detect_project_type(all_files); @@ -558,7 +559,18 @@ function process_directory(dir_path) { if (project_info.has_app_imports) { console.error('Error: This SvelteKit project uses $app/* imports which cannot be converted.'); console.error('The playground does not support SvelteKit runtime features.'); - process.exit(1); + + const fallback_dir = path.resolve(base_dir, '..', '..', 'kit-sandbox-tmp'); + const should_copy = await prompt_download_to_kit_sandbox_tmp(fallback_dir); + + if (!should_copy) { + process.exit(1); + } + + copy_project_to_directory(dir_path, fallback_dir); + console.log(`Project copied to ${fallback_dir}`); + + return null; } // Convert based on project type @@ -571,6 +583,66 @@ function process_directory(dir_path) { } } +/** + * Ask whether to copy the project to playgrounds/kit-sandbox-tmp + * @param {string} fallback_dir + * @returns {Promise} + */ +async function prompt_download_to_kit_sandbox_tmp(fallback_dir) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return false; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + try { + const answer = await rl.question( + `Would you like to copy this project into ${fallback_dir} instead? [y/N] ` + ); + return /^(y|yes)$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** + * Copy a project directory while skipping generated and dependency folders + * @param {string} source_dir + * @param {string} target_dir + */ +function copy_project_to_directory(source_dir, target_dir) { + const skip_dirs = new Set(['node_modules', '.git', '.svelte-kit', 'build', 'dist']); + + if (fs.existsSync(target_dir)) { + fs.rmSync(target_dir, { recursive: true, force: true }); + } + + /** @param {string} from_dir */ + function copy_recursive(from_dir) { + const relative_dir = path.relative(source_dir, from_dir); + const to_dir = relative_dir ? path.join(target_dir, relative_dir) : target_dir; + + fs.mkdirSync(to_dir, { recursive: true }); + + for (const entry of fs.readdirSync(from_dir, { withFileTypes: true })) { + if (entry.isDirectory() && skip_dirs.has(entry.name)) { + continue; + } + + const source_path = path.join(from_dir, entry.name); + const target_path = path.join(to_dir, entry.name); + + if (entry.isDirectory()) { + copy_recursive(source_path); + } else if (entry.isFile()) { + fs.copyFileSync(source_path, target_path); + } + } + } + + copy_recursive(source_dir); +} + /** * Reset a directory so it exists and is empty * @param {string} dir_path @@ -602,7 +674,7 @@ let files; // Check if it's a local directory first (before URL parsing) if (is_local) { console.log(`Processing local directory: ${url_arg}`); - files = process_directory(url_arg); + files = await process_directory(url_arg); } else if (resolved_test_path) { // Copy files from test console.log(`Processing test ${url_arg}`); @@ -616,21 +688,21 @@ if (is_local) { }); } else if (url && is_github_url(url)) { // GitHub repository handling - await with_tmp_dir(base_dir, (tmp_dir) => { + await with_tmp_dir(base_dir, async (tmp_dir) => { clone_github_repo(url, tmp_dir); - files = process_directory(tmp_dir); + files = await process_directory(tmp_dir); }); } else if (url && is_stackblitz_github_url(url)) { // StackBlitz GitHub project handling (redirect to GitHub clone) - await with_tmp_dir(base_dir, (tmp_dir) => { + await with_tmp_dir(base_dir, async (tmp_dir) => { clone_stackblitz_github_project(url, tmp_dir); - files = process_directory(tmp_dir); + files = await process_directory(tmp_dir); }); } else if (url && is_stackblitz_edit_url(url)) { // StackBlitz edit URLs - use browser automation to download await with_tmp_dir(base_dir, async (tmp_dir) => { await download_stackblitz_project(url, tmp_dir); - files = process_directory(tmp_dir); + files = await process_directory(tmp_dir); }); } else if (url && url.origin === 'https://svelte.dev' && url.pathname.startsWith('/playground/')) { // Svelte playground URL handling (existing logic) @@ -680,6 +752,10 @@ if (is_local) { process.exit(1); } +if (files === null) { + process.exit(0); +} + // Output files if (create_test_name) { const test_parts = create_test_name.split('/').filter(Boolean); From 48dc9b40c0249ffba7067f3c3605e4f1fb376b5e Mon Sep 17 00:00:00 2001 From: Rohan Santhosh Kumar <181558744+Rohan5commit@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:12:58 +0800 Subject: [PATCH 21/73] docs: fix spacing around option tag in bind docs (#18114) ## Summary - Fix the missing space before `