From bc4dc1d10d54ecbc61398a248f8a45a4f4bcfa13 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:28:32 +0100 Subject: [PATCH 1/6] fix: avoid erroneous async derived expressions for blocks (#17604) fixes #17595 When an if/key/etc block has an expression that depends on an async blocker (e.g., is inside a component with top level `await`), the compiler incorrectly treats the expression as async - even when the expression itself contains no `await`. This causes the expression to be added to `$.async`'s `expressions` array, which wraps it in an `async_derived`. This is not only unnecessary but also buggy: it breaks the direct reactive connection between the source and its dependent effects, causing inconsistent effect executions. The fix is to only add expressions to `$.async`'s `expressions` array when they actually contain an `await`. When a branch is speculatively marked for destruction (condition temporarily falsy), its child effects are reset to `CLEAN` to prevent them running in a doomed branch (as of #17581). However, if the branch survives (condition becomes truthy again), those effects remain `CLEAN` and never run - the source was already marked dirty before the reset, so no new dirty marking occurs. The fix is to change `skipped_effects` from a `Set` to a `Map` that tracks which child effects were dirty/maybe_dirty before being reset. When a branch is unskipped (survives), restore their status and reschedule them. --------- Co-authored-by: Rich Harris --- .changeset/some-teams-pay.md | 5 ++ .changeset/yummy-insects-wonder.md | 5 ++ .../3-transform/client/visitors/EachBlock.js | 15 +++-- .../3-transform/client/visitors/HtmlTag.js | 14 ++-- .../3-transform/client/visitors/IfBlock.js | 14 ++-- .../3-transform/client/visitors/KeyBlock.js | 32 +++++---- .../client/visitors/SvelteElement.js | 14 ++-- .../internal/client/dom/blocks/branches.js | 8 +-- .../src/internal/client/dom/blocks/each.js | 4 +- .../src/internal/client/reactivity/batch.js | 65 +++++++++++++++---- .../svelte/tests/runtime-legacy/shared.ts | 4 +- .../Child.svelte | 9 +++ .../_config.js | 16 +++++ .../main.svelte | 14 ++++ 14 files changed, 166 insertions(+), 53 deletions(-) create mode 100644 .changeset/some-teams-pay.md create mode 100644 .changeset/yummy-insects-wonder.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte diff --git a/.changeset/some-teams-pay.md b/.changeset/some-teams-pay.md new file mode 100644 index 0000000000..d7ae69566d --- /dev/null +++ b/.changeset/some-teams-pay.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid erroneous async derived expressions for blocks diff --git a/.changeset/yummy-insects-wonder.md b/.changeset/yummy-insects-wonder.md new file mode 100644 index 0000000000..6e705cd8cf --- /dev/null +++ b/.changeset/yummy-insects-wonder.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reschedule effects inside unskipped branches diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index c0bfe272e5..1dbc34fdc3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -312,10 +312,10 @@ export function EachBlock(node, context) { declarations.push(b.let(node.index, index)); } - const is_async = node.metadata.expression.is_async(); + const has_await = node.metadata.expression.has_await; - const get_collection = b.thunk(collection, node.metadata.expression.has_await); - const thunk = is_async ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection; + const get_collection = b.thunk(collection, has_await); + const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection; const render_args = [b.id('$$anchor'), item]; if (uses_index || collection_id) render_args.push(index); @@ -342,15 +342,18 @@ export function EachBlock(node, context) { statements.unshift(b.stmt(b.call('$.validate_each_keys', thunk, key_function))); } - if (is_async) { + if (node.metadata.expression.is_async()) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([get_collection]), - b.arrow([context.state.node, b.id('$$collection')], b.block(statements)) + has_await ? b.array([get_collection]) : b.void0, + b.arrow( + has_await ? [context.state.node, b.id('$$collection')] : [context.state.node], + b.block(statements) + ) ) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 0567edc610..2706cf7f0a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -11,10 +11,11 @@ import { build_expression } from './shared/utils.js'; export function HtmlTag(node, context) { context.state.template.push_comment(); - const is_async = node.metadata.expression.is_async(); + const has_await = node.metadata.expression.has_await; + const has_blockers = node.metadata.expression.has_blockers(); const expression = build_expression(context, node.expression, node.metadata.expression); - const html = is_async ? b.call('$.get', b.id('$$html')) : expression; + const html = has_await ? b.call('$.get', b.id('$$html')) : expression; const is_svg = context.state.metadata.namespace === 'svg'; const is_mathml = context.state.metadata.namespace === 'mathml'; @@ -31,15 +32,18 @@ export function HtmlTag(node, context) { ); // push into init, so that bindings run afterwards, which might trigger another run and override hydration - if (is_async) { + if (has_await || has_blockers) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([b.thunk(expression, node.metadata.expression.has_await)]), - b.arrow([context.state.node, b.id('$$html')], b.block([statement])) + has_await ? b.array([b.thunk(expression, true)]) : b.void0, + b.arrow( + has_await ? [context.state.node, b.id('$$html')] : [context.state.node], + b.block([statement]) + ) ) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js index fcbb59ba74..c0e66635df 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js @@ -25,10 +25,11 @@ export function IfBlock(node, context) { statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate))); } - const is_async = node.metadata.expression.is_async(); + const has_await = node.metadata.expression.has_await; + const has_blockers = node.metadata.expression.has_blockers(); const expression = build_expression(context, node.test, node.metadata.expression); - const test = is_async ? b.call('$.get', b.id('$$condition')) : expression; + const test = has_await ? b.call('$.get', b.id('$$condition')) : expression; /** @type {Expression[]} */ const args = [ @@ -72,15 +73,18 @@ export function IfBlock(node, context) { statements.push(add_svelte_meta(b.call('$.if', ...args), node, 'if')); - if (is_async) { + if (has_await || has_blockers) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([b.thunk(expression, node.metadata.expression.has_await)]), - b.arrow([context.state.node, b.id('$$condition')], b.block(statements)) + has_await ? b.array([b.thunk(expression, true)]) : b.void0, + b.arrow( + has_await ? [context.state.node, b.id('$$condition')] : [context.state.node], + b.block(statements) + ) ) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js index d050155e8b..143a4e8edd 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js @@ -11,29 +11,35 @@ import { build_expression, add_svelte_meta } from './shared/utils.js'; export function KeyBlock(node, context) { context.state.template.push_comment(); - const is_async = node.metadata.expression.is_async(); + const has_await = node.metadata.expression.has_await; + const has_blockers = node.metadata.expression.has_blockers(); const expression = build_expression(context, node.expression, node.metadata.expression); - const key = b.thunk(is_async ? b.call('$.get', b.id('$$key')) : expression); + const key = b.thunk(has_await ? b.call('$.get', b.id('$$key')) : expression); const body = /** @type {Expression} */ (context.visit(node.fragment)); - let statement = add_svelte_meta( + const statement = add_svelte_meta( b.call('$.key', context.state.node, key, b.arrow([b.id('$$anchor')], body)), node, 'key' ); - if (is_async) { - statement = b.stmt( - b.call( - '$.async', - context.state.node, - node.metadata.expression.blockers(), - b.array([b.thunk(expression, node.metadata.expression.has_await)]), - b.arrow([context.state.node, b.id('$$key')], b.block([statement])) + if (has_await || has_blockers) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + node.metadata.expression.blockers(), + has_await ? b.array([b.thunk(expression, true)]) : b.void0, + b.arrow( + has_await ? [context.state.node, b.id('$$key')] : [context.state.node], + b.block([statement]) + ) + ) ) ); + } else { + context.state.init.push(statement); } - - context.state.init.push(statement); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js index c8192cf00a..10024298fa 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js @@ -93,10 +93,11 @@ export function SvelteElement(node, context) { ); } - const is_async = node.metadata.expression.is_async(); + const has_await = node.metadata.expression.has_await; + const has_blockers = node.metadata.expression.has_blockers(); const expression = /** @type {Expression} */ (context.visit(node.tag)); - const get_tag = b.thunk(is_async ? b.call('$.get', b.id('$$tag')) : expression); + const get_tag = b.thunk(has_await ? b.call('$.get', b.id('$$tag')) : expression); /** @type {Statement[]} */ const inner = inner_context.state.init; @@ -139,15 +140,18 @@ export function SvelteElement(node, context) { ) ); - if (is_async) { + if (has_await || has_blockers) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([b.thunk(expression, node.metadata.expression.has_await)]), - b.arrow([context.state.node, b.id('$$tag')], b.block(statements)) + has_await ? b.array([b.thunk(expression, true)]) : b.void0, + b.arrow( + has_await ? [context.state.node, b.id('$$tag')] : [context.state.node], + b.block(statements) + ) ) ) ); diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 527f0b0a8f..6b77903574 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -200,17 +200,17 @@ export class BranchManager { if (defer) { for (const [k, effect] of this.#onscreen) { if (k === key) { - batch.skipped_effects.delete(effect); + batch.unskip_effect(effect); } else { - batch.skipped_effects.add(effect); + batch.skip_effect(effect); } } for (const [k, branch] of this.#offscreen) { if (k === key) { - batch.skipped_effects.delete(branch.effect); + batch.unskip_effect(branch.effect); } else { - batch.skipped_effects.add(branch.effect); + batch.skip_effect(branch.effect); } } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 232656ec11..6eaeac0f38 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -257,7 +257,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (item.i) internal_set(item.i, index); if (defer) { - batch.skipped_effects.delete(item.e); + batch.unskip_effect(item.e); } } else { item = create_item( @@ -299,7 +299,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (defer) { for (const [key, item] of items) { if (!keys.has(key)) { - batch.skipped_effects.add(item.e); + batch.skip_effect(item.e); } } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2b6e84889b..cef2df4716 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -130,11 +130,13 @@ export class Batch { #maybe_dirty_effects = new Set(); /** - * A set of branches that still exist, but will be destroyed when this batch - * is committed — we skip over these during `process` - * @type {Set} + * A map of branches that still exist, but will be destroyed when this batch + * is committed — we skip over these during `process`. + * The value contains child effects that were dirty/maybe_dirty before being reset, + * so they can be rescheduled if the branch survives. + * @type {Map} */ - skipped_effects = new Set(); + #skipped_branches = new Map(); is_fork = false; @@ -144,6 +146,38 @@ export class Batch { return this.is_fork || this.#blocking_pending > 0; } + /** + * Add an effect to the #skipped_branches map and reset its children + * @param {Effect} effect + */ + skip_effect(effect) { + if (!this.#skipped_branches.has(effect)) { + this.#skipped_branches.set(effect, { d: [], m: [] }); + } + } + + /** + * Remove an effect from the #skipped_branches map and reschedule + * any tracked dirty/maybe_dirty child effects + * @param {Effect} effect + */ + unskip_effect(effect) { + 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); + schedule_effect(e); + } + + for (e of tracked.m) { + set_signal_status(e, MAYBE_DIRTY); + schedule_effect(e); + } + } + } + /** * * @param {Effect[]} root_effects @@ -172,8 +206,8 @@ export class Batch { this.#defer_effects(render_effects); this.#defer_effects(effects); - for (const e of this.skipped_effects) { - reset_branch(e); + for (const [e, t] of this.#skipped_branches) { + reset_branch(e, t); } } else { // append/remove branches @@ -220,7 +254,7 @@ export class Batch { var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; - var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect); + var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect); // Inside a `` with a pending snippet, // all effects are deferred until the boundary resolves @@ -807,7 +841,8 @@ export function schedule_effect(signal) { var flags = effect.f; // if the effect is being scheduled because a parent (each/await/etc) block - // updated an internal source, bail out or we'll cause a second flush + // updated an internal source, or because a branch is being unskipped, + // bail out or we'll cause a second flush if ( is_flushing && effect === active_effect && @@ -887,20 +922,28 @@ export function eager(fn) { /** * Mark all the effects inside a skipped branch CLEAN, so that - * they can be correctly rescheduled later + * they can be correctly rescheduled later. Tracks dirty and maybe_dirty + * effects so they can be rescheduled if the branch survives. * @param {Effect} effect + * @param {{ d: Effect[], m: Effect[] }} tracked */ -function reset_branch(effect) { +function reset_branch(effect, tracked) { // clean branch = nothing dirty inside, no need to traverse further if ((effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) !== 0) { return; } + if ((effect.f & DIRTY) !== 0) { + tracked.d.push(effect); + } else if ((effect.f & MAYBE_DIRTY) !== 0) { + tracked.m.push(effect); + } + set_signal_status(effect, CLEAN); var e = effect.first; while (e !== null) { - reset_branch(e); + reset_branch(e, tracked); e = e.next; } } diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 13975c68ee..8c29a6ada2 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -47,9 +47,9 @@ export interface RuntimeTest = Record; /** Temporarily skip specific modes, without skipping the entire test */ skip_mode?: Array<'server' | 'async-server' | 'client' | 'hydrate'>; - /** Skip if running with process.env.NO_ASYNC */ + /** Skip if running with process.env.SVELTE_NO_ASYNC */ skip_no_async?: boolean; - /** Skip if running without process.env.NO_ASYNC */ + /** Skip if running without process.env.SVELTE_NO_ASYNC */ skip_async?: boolean; html?: string; ssrHtml?: string; diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/Child.svelte new file mode 100644 index 0000000000..fecbe222e3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/Child.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js new file mode 100644 index 0000000000..2f371bc6b7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + + assert.deepEqual(logs, ['promise resolved with:', 'some-id']); + + const button = target.querySelector('button'); + button?.click(); + await tick(); + + assert.deepEqual(logs, ['promise resolved with:', 'some-id']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte new file mode 100644 index 0000000000..d5cdd5967e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unmount-undefined-props/main.svelte @@ -0,0 +1,14 @@ + + +{#if active} + +{/if} + + From baba15ab981a3b090eaabefe3425f5f4ea9a8074 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Thu, 5 Feb 2026 00:31:33 +0800 Subject: [PATCH 2/6] fix: avoid 'node:crypto' cloudflare warnings (#17612) * fix: avoid 'node:crypto' cloudflare warnings * format * changeset * Apply suggestion from @teemingc --- .changeset/violet-pans-know.md | 5 +++++ packages/svelte/src/internal/server/crypto.js | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/violet-pans-know.md diff --git a/.changeset/violet-pans-know.md b/.changeset/violet-pans-know.md new file mode 100644 index 0000000000..59bf1dcaaf --- /dev/null +++ b/.changeset/violet-pans-know.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid Cloudflare warnings about not having the "node:crypto" module diff --git a/packages/svelte/src/internal/server/crypto.js b/packages/svelte/src/internal/server/crypto.js index 8727635481..9bb6ecdd39 100644 --- a/packages/svelte/src/internal/server/crypto.js +++ b/packages/svelte/src/internal/server/crypto.js @@ -12,7 +12,8 @@ export async function sha256(data) { crypto ??= globalThis.crypto?.subtle?.digest ? globalThis.crypto : // @ts-ignore - we don't install node types in the prod build - (await import('node:crypto')).webcrypto; + // don't use 'node:crypto' because static analysers will think we rely on node when we don't + (await import('node:' + 'crypto')).webcrypto; const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data)); From 05229d96823cc88d11c0b4a81b357cdb67f69326 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Thu, 5 Feb 2026 00:32:33 +0800 Subject: [PATCH 3/6] chore: remove SvelteKit data attributes from elements.d.ts (#17613) * Remove SvelteKit data attributes from elements.d.ts Removed SvelteKit specific data attributes from elements.d.ts. * Remove SvelteKit data attributes from elements.d.ts Removed SvelteKit data attributes from elements.d.ts. --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/quiet-buttons-bathe.md | 5 +++++ packages/svelte/elements.d.ts | 17 ----------------- 2 files changed, 5 insertions(+), 17 deletions(-) create mode 100644 .changeset/quiet-buttons-bathe.md diff --git a/.changeset/quiet-buttons-bathe.md b/.changeset/quiet-buttons-bathe.md new file mode 100644 index 0000000000..419a5569b4 --- /dev/null +++ b/.changeset/quiet-buttons-bathe.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +chore: remove SvelteKit data attributes from elements.d.ts diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index fa74124472..885004dd2a 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -851,23 +851,6 @@ export interface HTMLAttributes extends AriaAttributes, D readonly 'bind:offsetWidth'?: number | undefined | null; readonly 'bind:offsetHeight'?: number | undefined | null; - // SvelteKit - 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-preload-code'?: - | true - | '' - | 'eager' - | 'viewport' - | 'hover' - | 'tap' - | 'off' - | undefined - | null; - 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; - 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; - 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; - // allow any data- attribute [key: `data-${string}`]: any; From eb63a6bbbaffec07890ae181b02edf4c52e2586a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:52:30 -0500 Subject: [PATCH 4/6] Version Packages (#17614) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/quiet-buttons-bathe.md | 5 ----- .changeset/some-teams-pay.md | 5 ----- .changeset/violet-pans-know.md | 5 ----- .changeset/yummy-insects-wonder.md | 5 ----- packages/svelte/CHANGELOG.md | 12 ++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 .changeset/quiet-buttons-bathe.md delete mode 100644 .changeset/some-teams-pay.md delete mode 100644 .changeset/violet-pans-know.md delete mode 100644 .changeset/yummy-insects-wonder.md diff --git a/.changeset/quiet-buttons-bathe.md b/.changeset/quiet-buttons-bathe.md deleted file mode 100644 index 419a5569b4..0000000000 --- a/.changeset/quiet-buttons-bathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -chore: remove SvelteKit data attributes from elements.d.ts diff --git a/.changeset/some-teams-pay.md b/.changeset/some-teams-pay.md deleted file mode 100644 index d7ae69566d..0000000000 --- a/.changeset/some-teams-pay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid erroneous async derived expressions for blocks diff --git a/.changeset/violet-pans-know.md b/.changeset/violet-pans-know.md deleted file mode 100644 index 59bf1dcaaf..0000000000 --- a/.changeset/violet-pans-know.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid Cloudflare warnings about not having the "node:crypto" module diff --git a/.changeset/yummy-insects-wonder.md b/.changeset/yummy-insects-wonder.md deleted file mode 100644 index 6e705cd8cf..0000000000 --- a/.changeset/yummy-insects-wonder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reschedule effects inside unskipped branches diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index c7c38302e8..a71effd5dd 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.49.2 + +### Patch Changes + +- chore: remove SvelteKit data attributes from elements.d.ts ([#17613](https://github.com/sveltejs/svelte/pull/17613)) + +- fix: avoid erroneous async derived expressions for blocks ([#17604](https://github.com/sveltejs/svelte/pull/17604)) + +- fix: avoid Cloudflare warnings about not having the "node:crypto" module ([#17612](https://github.com/sveltejs/svelte/pull/17612)) + +- fix: reschedule effects inside unskipped branches ([#17604](https://github.com/sveltejs/svelte/pull/17604)) + ## 5.49.1 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fd4b98679e..48f492783f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.49.1", + "version": "5.49.2", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 557b1ff156..31538c3fe7 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.49.1'; +export const VERSION = '5.49.2'; export const PUBLIC_VERSION = '5'; From 4f41e816baa007fb6a4e31164da49576e87e342e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 4 Feb 2026 13:07:46 -0500 Subject: [PATCH 5/6] fix: ensure infinite effect loops are cleared after flushing (#17601) * failing effect-loop-infinite test * fix --- .changeset/chatty-mammals-find.md | 5 +++++ .../svelte/src/internal/client/reactivity/batch.js | 3 ++- packages/svelte/tests/runtime-legacy/shared.ts | 2 ++ .../samples/effect-loop-infinite/_config.js | 11 ++++++----- 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 .changeset/chatty-mammals-find.md diff --git a/.changeset/chatty-mammals-find.md b/.changeset/chatty-mammals-find.md new file mode 100644 index 0000000000..373dc0059a --- /dev/null +++ b/.changeset/chatty-mammals-find.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure infinite effect loops are cleared after flushing diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index cef2df4716..9bf93c873f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -647,8 +647,9 @@ function flush_effects() { } } } finally { - is_flushing = false; + queued_root_effects = []; + is_flushing = false; last_scheduled_effect = null; if (DEV) { diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 8c29a6ada2..c5317f822e 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -521,6 +521,8 @@ async function run_test_variant( errors, hydrate: hydrate_fn }); + + flushSync(); } if (config.runtime_error && !unhandled_rejection) { diff --git a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js index 57f60c2b44..44cf5b09e2 100644 --- a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js @@ -11,11 +11,12 @@ export default test({ test({ assert, errors }) { const [button] = document.querySelectorAll('button'); - try { + assert.throws(() => { flushSync(() => button.click()); - } catch (e) { - assert.equal(errors.length, 1); // for whatever reason we can't get the name which should be 'updated at' - assert.ok(/** @type {Error} */ (e).message.startsWith('effect_update_depth_exceeded')); - } + }, /effect_update_depth_exceeded/); + + assert.equal(errors.length, 1); + + assert.doesNotThrow(flushSync); } }); From d7a8e3d1300fbb802b84a6ebf0f3f71ed734a556 Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 4 Feb 2026 21:51:50 +0200 Subject: [PATCH 6/6] fix: emit `each_key_duplicate` error in production (#16724) * fix: emit `each_key_duplicate` error in production * fix: preserve key * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * fix: ensure keys are validated * fix silly test name * fix: cover other case of duplicate keys * emit error on hydration * ensure the error is handled * drop useless tests * unused * finish merge * add lost check back * chore: bump playwright (#17565) * chore: bump playwright * maybe this will help somehow? * err whatever * fix * chore: allow testing in production env 2 (#17590) * Revert "chore: allow testing in production env (#16840)" This reverts commit ffd65e90febc29feaca48e142126a4087fcaca9f. * new approach * fix: handle renderer.run rejections (#17591) * fix: handle renderer run rejections * add test * changeset * simplify * explanatory comment --------- Co-authored-by: Antonio Bennett Co-authored-by: Rich Harris * fix: only create async functions in SSR output when necessary (#17593) * fix: only create async functions in SSR output when necessary * actually... * simplify generated code a bit more * simplify * fix: merge consecutive text nodes during hydration for large text content (#17587) * fix: merge consecutive text nodes during hydration for large text content Fixes #17582 Browsers automatically split text nodes exceeding 65536 characters into multiple consecutive text nodes during HTML parsing. This causes hydration mismatches when Svelte expects a single text node. The fix merges consecutive text nodes during hydration by: - Detecting when the current node is a text node - Finding all consecutive text node siblings - Merging their content into the first text node - Removing the extra text nodes This restores correct hydration behavior for large text content. * add test, fix * fix * fix * changeset --------- Co-authored-by: Miner Co-authored-by: Rich Harris * Version Packages (#17585) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Revert "drop useless tests" This reverts commit 65f77ef8409d6e8c91163f36853d36b05ec01ae8. * update tests * fix test * we don't need to expose this function any more * figured it out... we cant have errors during reconcile * simplify * tweak * unused * revert no-longer-needed change * unused --------- Co-authored-by: Rich Harris Co-authored-by: Antonio Bennett <31296212+Antonio-Bennett@users.noreply.github.com> Co-authored-by: Antonio Bennett Co-authored-by: FORMI <239411042+Richman018@users.noreply.github.com> Co-authored-by: Miner Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/sharp-snakes-poke.md | 5 +++ .../3-transform/client/visitors/EachBlock.js | 4 -- .../src/internal/client/dom/blocks/each.js | 37 +++++++++++++++++++ packages/svelte/src/internal/client/index.js | 2 +- .../svelte/src/internal/client/validate.js | 34 ----------------- .../samples/keyed-each-unique-2/_config.js | 12 ++++++ .../samples/keyed-each-unique-2/main.svelte | 8 ++++ .../samples/keyed-each-unique-3/_config.js | 5 +++ .../samples/keyed-each-unique-3/main.svelte | 7 ++++ .../samples/keyed-each-unique/_config.js | 12 ++++++ .../samples/keyed-each-unique/main.svelte | 8 ++++ 11 files changed, 95 insertions(+), 39 deletions(-) create mode 100644 .changeset/sharp-snakes-poke.md create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte diff --git a/.changeset/sharp-snakes-poke.md b/.changeset/sharp-snakes-poke.md new file mode 100644 index 0000000000..7f7f8aa7b2 --- /dev/null +++ b/.changeset/sharp-snakes-poke.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: emit `each_key_duplicate` error in production diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 1dbc34fdc3..b2724fa90f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -338,10 +338,6 @@ export function EachBlock(node, context) { const statements = [add_svelte_meta(b.call('$.each', ...args), node, 'each')]; - if (dev && node.metadata.keyed) { - statements.unshift(b.stmt(b.call('$.validate_each_keys', thunk, key_function))); - } - if (node.metadata.expression.is_async()) { context.state.init.push( b.stmt( diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6eaeac0f38..25f7cf91eb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -40,6 +40,7 @@ import { get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; +import * as e from '../../errors.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b @@ -290,6 +291,15 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } + if (length > keys.size) { + if (DEV) { + validate_each_keys(array, get_key); + } else { + // in prod, the additional information isn't printed, so don't bother computing it + e.each_key_duplicate('', '', ''); + } + } + // remove excess nodes if (hydrating && length > 0) { set_hydrate_node(skip_nodes()); @@ -676,3 +686,30 @@ function link(state, prev, next) { next.prev = prev; } } + +/** + * @param {Array} array + * @param {(item: any, index: number) => string} key_fn + * @returns {void} + */ +function validate_each_keys(array, key_fn) { + const keys = new Map(); + const length = array.length; + + for (let i = 0; i < length; i++) { + const key = key_fn(array[i], i); + + if (keys.has(key)) { + const a = String(keys.get(key)); + const b = String(i); + + /** @type {string | null} */ + let k = String(key); + if (k.startsWith('[object ')) k = null; + + e.each_key_duplicate(a, b, k); + } + + keys.set(key, i); + } +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 1d9f7dfff7..7fcaf77dc5 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -158,7 +158,7 @@ export { deep_read_state, active_effect } from './runtime.js'; -export { validate_binding, validate_each_keys } from './validate.js'; +export { validate_binding } from './validate.js'; export { raf } from './timing.js'; export { proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 48a44db304..a169225f1e 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,45 +1,11 @@ /** @import { Blocker } from '#client' */ import { dev_current_component_function } from './context.js'; -import { is_array } from '../shared/utils.js'; -import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; import { render_effect } from './reactivity/effects.js'; import * as w from './warnings.js'; import { capture_store_binding } from './reactivity/store.js'; import { run_after_blockers } from './reactivity/async.js'; -/** - * @param {() => any} collection - * @param {(item: any, index: number) => string} key_fn - * @returns {void} - */ -export function validate_each_keys(collection, key_fn) { - render_effect(() => { - const keys = new Map(); - const maybe_array = collection(); - const array = is_array(maybe_array) - ? maybe_array - : maybe_array == null - ? [] - : Array.from(maybe_array); - const length = array.length; - for (let i = 0; i < length; i++) { - const key = key_fn(array[i], i); - if (keys.has(key)) { - const a = String(keys.get(key)); - const b = String(i); - - /** @type {string | null} */ - let k = String(key); - if (k.startsWith('[object ')) k = null; - - e.each_key_duplicate(a, b, k); - } - keys.set(key, i); - } - }); -} - /** * @param {string} binding * @param {Blocker[]} blockers diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte new file mode 100644 index 0000000000..f8ba50d866 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js new file mode 100644 index 0000000000..7e1840200a --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + error: 'each_key_duplicate' +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte new file mode 100644 index 0000000000..a05781bcb9 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte @@ -0,0 +1,7 @@ + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte new file mode 100644 index 0000000000..3d52179372 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each}