From f2648b353778493494c6dd3a7d020b1f08eb5def Mon Sep 17 00:00:00 2001 From: Nic Polumeyv Date: Mon, 24 Aug 2026 13:01:48 -0400 Subject: [PATCH 01/27] fix: end a restored reaction context at the end of its synchronous segment (#18694) When an async expression resumes after a pickled `await`, the thunk returned by `save()` in `reactivity/async.js` calls `restore()` to re-arm `active_reaction` for the rest of the expression, then disarms it with `queue_micro_task(unset_context)`. Any microtask already queued before that one runs inside the restored context. If it writes to a source, `set()` throws `state_unsafe_mutation` in production, since the guard is not dev-only. #18453 introduced the queued disarm and noted this case in review as unavoidable. SvelteKit hits it in practice: its fetch continuations write to internal `$state` (sveltejs/kit#16914), and a user's `$derived((await q()).length)` resuming in the same tick makes that write throw and drops the update signal. The context restored by a `save` thunk now ends with the synchronous segment it was restored in. A `restored` flag is set by the thunk and consumed on entry to `save` and `track_reactivity_loss`, so every suspension ends it; once an expression contains a pickled await, the analysis pickles every later await in it too (`has_pickled_await` on `ExpressionMetadata`), so a trailing await compiles to `$.save` rather than a bare `await`. At the end of the body, `async_thunk` in `3-transform/client/utils.js` wraps the return expression in `$.unsave(...)` when the metadata has a pickled await. If the body throws instead, the context is unset by `async_derived`'s existing `finally`, as before. The queued microtask in `save` is removed. Output is unchanged for expressions that pickle nothing (`$derived(await a)` compiles byte for byte the same). Expressions with a pickled await gain one `$.unsave(` call per body, and their trailing await becomes a `$.save`, 4 to 6 bytes gzipped in the added tests. At runtime a boolean write replaces a queued microtask per resume. `bench:compare` shows no difference outside run-to-run noise. Two runtime tests reproduce the throw without any library involved, one in dev and one with the prod `await` shape, and fail on `main`. --------- Co-authored-by: Simon Holthausen --- .changeset/quiet-context-segment.md | 5 + .../src/compiler/phases/2-analyze/index.js | 4 +- .../2-analyze/visitors/AwaitExpression.js | 14 ++- .../2-analyze/visitors/CallExpression.js | 2 +- .../phases/3-transform/client/utils.js | 43 +++++-- .../3-transform/client/visitors/AwaitBlock.js | 12 +- .../3-transform/client/visitors/ConstTag.js | 4 +- .../client/visitors/DeclarationTag.js | 3 +- .../3-transform/client/visitors/EachBlock.js | 5 +- .../3-transform/client/visitors/HtmlTag.js | 3 +- .../3-transform/client/visitors/IfBlock.js | 3 +- .../3-transform/client/visitors/KeyBlock.js | 3 +- .../client/visitors/SvelteElement.js | 3 +- .../client/visitors/VariableDeclaration.js | 15 ++- .../client/visitors/shared/utils.js | 10 +- packages/svelte/src/compiler/phases/nodes.js | 4 + .../svelte/src/compiler/phases/types.d.ts | 2 +- packages/svelte/src/internal/client/index.js | 1 + .../src/internal/client/reactivity/async.js | 26 ++++- .../async-save-await-block-pending/_config.js | 20 ++++ .../main.svelte | 14 +++ .../_config.js | 22 ++++ .../main.svelte | 20 ++++ .../_config.js | 21 ++++ .../main.svelte | 21 ++++ .../async-save-throwing-expression/_config.js | 20 ++++ .../main.svelte | 26 +++++ .../_expected/client/index.svelte.js | 14 ++- .../_expected/client/index.svelte.js | 110 +++++++++++------- .../_expected/client/index.svelte.js | 30 ++++- 30 files changed, 393 insertions(+), 87 deletions(-) create mode 100644 .changeset/quiet-context-segment.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte diff --git a/.changeset/quiet-context-segment.md b/.changeset/quiet-context-segment.md new file mode 100644 index 0000000000..7af36f8ed3 --- /dev/null +++ b/.changeset/quiet-context-segment.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: end a restored reaction context at the end of its synchronous segment diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 45f703901d..f6fb14a870 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -285,7 +285,7 @@ export function analyze_module(source, options) { runes: true, immutable: true, tracing: false, - async_deriveds: new Set(), + async_deriveds: new Map(), comments, classes: new Map(), pickled_awaits: new Set() @@ -557,7 +557,7 @@ export function analyze_component(root, source, options) { source, snippet_renderers: new Map(), snippets: new Set(), - async_deriveds: new Set(), + async_deriveds: new Map(), pickled_awaits: new Set(), instance_body: { sync: [], diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index 5a54d62471..c29f469ad0 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -1,6 +1,7 @@ /** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */ /** @import { Context } from '../types' */ /** @import { AST } from '#compiler' */ +/** @import { ExpressionMetadata } from '../../nodes.js' */ import * as e from '../../../errors.js'; /** @@ -10,15 +11,20 @@ import * as e from '../../../errors.js'; export function AwaitExpression(node, context) { const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1; - // preserve context for awaits that precede other expressions in template or `$derived(...)` if ( is_reactive_expression( context.path, context.state.derived_function_depth === context.state.function_depth - ) && - !is_last_evaluated_expression(context.path, node) + ) ) { - context.state.analysis.pickled_awaits.add(node); + const expression = /** @type {ExpressionMetadata} */ (context.state.expression); + + // preserve context for awaits that precede other expressions in template or `$derived(...)`, + // and for any await that follows one, so the restored context ends at the next suspension + if (expression.has_pickled_await || !is_last_evaluated_expression(context.path, node)) { + context.state.analysis.pickled_awaits.add(node); + expression.has_pickled_await = true; + } } let suspend = tla; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index d3524af4db..312a93e5fd 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -253,7 +253,7 @@ export function CallExpression(node, context) { }); if (expression.has_await) { - context.state.analysis.async_deriveds.add(node); + context.state.analysis.async_deriveds.set(node, expression); } // Tell surrounding declaration tag about metadata for correct calculation of blockers etc diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index d52b438d92..9cdfa5cae1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -2,6 +2,7 @@ /** @import { Binding } from '#compiler' */ /** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */ /** @import { Analysis } from '../../types.js' */ +/** @import { ExpressionMetadata } from '../../nodes.js' */ /** @import { Scope } from '../../scope.js' */ import * as b from '#compiler/builders'; import { is_simple_expression, save } from '../../../utils/ast.js'; @@ -164,20 +165,46 @@ export function should_proxy(node, scope) { return true; } +/** + * An async thunk. If an `await` inside restores the reaction context via `$.save`, + * the body exits through `$.unsave` so the context cannot leak into foreign microtasks + * that run before the returned promise settles + * @param {Expression | BlockStatement} body + * @param {ExpressionMetadata} metadata + */ +export function async_thunk(body, metadata) { + if (!metadata.has_pickled_await) { + return b.arrow([], body, true); + } + + const block = body.type === 'BlockStatement' ? body : b.block([b.return(body)]); + + return b.arrow( + [], + b.block([ + { + type: 'TryStatement', + block, + handler: null, + finalizer: b.block([b.stmt(b.call('$.unsave'))]) + } + ]), + true + ); +} + /** * Svelte legacy mode should use safe equals in most places, runes mode shouldn't * @param {ComponentClientTransformState} state * @param {Expression | BlockStatement} expression - * @param {boolean} [async] + * @param {ExpressionMetadata} [metadata] */ -export function create_derived(state, expression, async = false) { - const thunk = b.thunk(expression, async); - - if (async) { - return save(b.call('$.async_derived', thunk)); - } else { - return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk); +export function create_derived(state, expression, metadata) { + if (metadata?.has_await) { + return save(b.call('$.async_derived', async_thunk(expression, metadata))); } + + return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', b.thunk(expression)); } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index b8def178f2..986cd789d9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -1,9 +1,9 @@ /** @import { BlockStatement, Pattern, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */ -import { extract_identifiers, is_expression_async } from '../../../../utils/ast.js'; +import { extract_identifiers } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; -import { create_derived } from '../utils.js'; +import { async_thunk, create_derived } from '../utils.js'; import { get_value } from './shared/declarations.js'; import { build_expression, add_svelte_meta } from './shared/utils.js'; @@ -15,10 +15,10 @@ export function AwaitBlock(node, context) { context.state.template.push_comment(); // Visit {#await } first to ensure that scopes are in the correct order - const expression = b.thunk( - build_expression(context, node.expression, node.metadata.expression), - node.metadata.expression.has_await - ); + const input = build_expression(context, node.expression, node.metadata.expression); + const expression = node.metadata.expression.has_await + ? async_thunk(input, node.metadata.expression) + : b.thunk(input); let then_block; let catch_block; 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 63c84a507c..b3b2358e4c 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 @@ -19,7 +19,7 @@ export function ConstTag(node, context) { if (declaration.id.type === 'Identifier') { const init = build_expression(context, declaration.init, node.metadata.expression); - let expression = create_derived(context.state, init, node.metadata.expression.has_await); + let expression = create_derived(context.state, init, node.metadata.expression); if (dev) { expression = b.call('$.tag', expression, b.literal(declaration.id.name)); @@ -69,7 +69,7 @@ export function ConstTag(node, context) { b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) ]); - let expression = create_derived(context.state, block, node.metadata.expression.has_await); + let expression = create_derived(context.state, block, node.metadata.expression); if (dev) { expression = b.call('$.tag', expression, b.literal('[@const]')); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js index abec5e828f..3f78b53c90 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js @@ -3,6 +3,7 @@ /** @import { ComponentContext } from '../types' */ import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { add_state_transformers } from './shared/declarations.js'; /** @@ -85,5 +86,5 @@ export function add_async_declaration(context, metadata, ids, assignments, kind metadata.expression.has_await || assignments.some((assignment) => has_await_expression(assignment)); const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments); - run.thunks.push(b.thunk(body, has_await)); + run.thunks.push(has_await ? async_thunk(body, metadata.expression) : b.thunk(body)); } 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 b33eddd461..e0bd8d797a 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 @@ -12,6 +12,7 @@ import { import { dev } from '../../../../state.js'; import { extract_paths, object } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { get_value } from './shared/declarations.js'; import { build_expression, add_svelte_meta } from './shared/utils.js'; @@ -313,7 +314,9 @@ export function EachBlock(node, context) { const has_await = node.metadata.expression.has_await; - const get_collection = b.thunk(collection, has_await); + const get_collection = has_await + ? async_thunk(collection, node.metadata.expression) + : b.thunk(collection); const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection; const render_args = [b.id('$$anchor'), item]; 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 6c8b7c0354..910f0a1239 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 @@ -2,6 +2,7 @@ /** @import { ComponentContext } from '../types' */ import { is_ignored } from '../../../../state.js'; import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { build_expression } from './shared/utils.js'; /** @@ -46,7 +47,7 @@ export function HtmlTag(node, context) { '$.async', context.state.node, node.metadata.expression.blockers(), - has_await ? b.array([b.thunk(expression, true)]) : b.void0, + has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : 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 b0cd51ce3f..5dcb3991c2 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 @@ -2,6 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js'; /** @@ -117,7 +118,7 @@ export function IfBlock(node, context) { '$.async', context.state.node, node.metadata.expression.blockers(), - has_await ? b.array([b.thunk(expression, true)]) : b.void0, + has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : 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 143a4e8edd..1831e7abab 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 @@ -2,6 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js'; /** @@ -31,7 +32,7 @@ export function KeyBlock(node, context) { '$.async', context.state.node, node.metadata.expression.blockers(), - has_await ? b.array([b.thunk(expression, true)]) : b.void0, + has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0, b.arrow( has_await ? [context.state.node, b.id('$$key')] : [context.state.node], b.block([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 10024298fa..909b4e0b71 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 @@ -4,6 +4,7 @@ import { dev, locator } from '../../../../state.js'; import { is_text_attribute } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; +import { async_thunk } from '../utils.js'; import { determine_namespace_for_children } from '../../utils.js'; import { build_attribute_value, @@ -147,7 +148,7 @@ export function SvelteElement(node, context) { '$.async', context.state.node, node.metadata.expression.blockers(), - has_await ? b.array([b.thunk(expression, true)]) : b.void0, + has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0, b.arrow( has_await ? [context.state.node, b.id('$$tag')] : [context.state.node], b.block(statements) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index b9f4690179..246feaccf6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -6,7 +6,13 @@ import { extract_paths, save } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import * as assert from '../../../../utils/assert.js'; import { get_rune } from '../../../scope.js'; -import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js'; +import { + async_thunk, + get_prop_source, + is_prop_source, + is_state_source, + should_proxy +} from '../utils.js'; import { get_value } from './shared/declarations.js'; /** @@ -200,9 +206,10 @@ export function VariableDeclaration(node, context) { } if (rune === '$derived' || rune === '$derived.by') { - const is_async = context.state.analysis.async_deriveds.has( + const metadata = context.state.analysis.async_deriveds.get( /** @type {CallExpression} */ (init) ); + const is_async = metadata !== undefined; if (declarator.id.type === 'Identifier') { let expression = /** @type {Expression} */ (context.visit(value)); @@ -213,7 +220,7 @@ export function VariableDeclaration(node, context) { /** @type {Expression} */ let call = b.call( '$.async_derived', - b.thunk(expression, true), + async_thunk(expression, metadata), dev && b.literal(declarator.id.name), location ? b.literal(location) : undefined ); @@ -246,7 +253,7 @@ export function VariableDeclaration(node, context) { call = b.call( '$.async_derived', - b.thunk(expression, true), + async_thunk(expression, metadata), dev && b.literal( `[$derived ${declarator.id.type === 'ArrayPattern' ? 'iterable' : 'object'}]` diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index c305d53ea5..64ae573984 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -8,7 +8,7 @@ import { sanitize_template_string } from '../../../../../utils/sanitize_template import { regex_is_valid_identifier } from '../../../../patterns.js'; import is_reference from 'is-reference'; import { dev, is_ignored, locator, component_name } from '../../../../../state.js'; -import { build_getter, is_state_source } from '../../utils.js'; +import { async_thunk, build_getter, is_state_source } from '../../utils.js'; import { ExpressionMetadata } from '../../../../nodes.js'; /** @@ -16,10 +16,10 @@ import { ExpressionMetadata } from '../../../../nodes.js'; * from templates and replacing them with `$0`, `$1` etc */ export class Memoizer { - /** @type {Array<{ id: Identifier, expression: Expression }>} */ + /** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */ #sync = []; - /** @type {Array<{ id: Identifier, expression: Expression }>} */ + /** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */ #async = []; /** @type {Set} */ @@ -43,7 +43,7 @@ export class Memoizer { const id = b.id('#'); // filled in later - (metadata.has_await ? this.#async : this.#sync).push({ id, expression }); + (metadata.has_await ? this.#async : this.#sync).push({ id, expression, metadata }); return id; } @@ -84,7 +84,7 @@ export class Memoizer { if (this.#async.length === 0) return; // use `b.arrow` rather than `b.thunk` so that deferred async/template effects // always read live bindings rather than a possibly stale snapshot. - return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true))); + return b.array(this.#async.map((memo) => async_thunk(memo.expression, memo.metadata))); } sync_values() { diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index da7e73015b..fea447a7c5 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -77,6 +77,9 @@ export class ExpressionMetadata { /** True if the expression contains `await` */ has_await = false; + /** True if an `await` restores the reaction context afterwards, so the thunk must end it */ + has_pickled_await = false; + /** True if the expression includes a member expression */ has_member_expression = false; @@ -142,6 +145,7 @@ export class ExpressionMetadata { this.has_state ||= source.has_state; this.has_call ||= source.has_call; this.has_await ||= source.has_await; + this.has_pickled_await ||= source.has_pickled_await; this.has_member_expression ||= source.has_member_expression; this.has_assignment ||= source.has_assignment; this.#blockers = null; // so that blockers are recalculated diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts index a1a85ce145..38fa80ac4b 100644 --- a/packages/svelte/src/compiler/phases/types.d.ts +++ b/packages/svelte/src/compiler/phases/types.d.ts @@ -63,7 +63,7 @@ export interface Analysis { accessors: boolean; /** A set of deriveds that contain `await` expressions */ - async_deriveds: Set; + async_deriveds: Map; /** Awaits needing context preservation */ pickled_awaits: Set; } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index fa6f9dda39..6cdf81c155 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -103,6 +103,7 @@ export { run, save, track_reactivity_loss, + unsave, run_after_blockers, wait } from './reactivity/async.js'; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 2e7df92dc0..a75f97a54f 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -8,7 +8,6 @@ import { set_component_context, set_dev_stack } from '../context.js'; -import { Boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -25,7 +24,6 @@ import { set_reactivity_loss_tracker } from './deriveds.js'; import { aborted } from './effects.js'; -import { queue_micro_task } from '../dom/task.js'; /** * @param {Blocker[]} blockers @@ -156,6 +154,9 @@ export function capture() { }; } +/** `true` between a `save` thunk restoring a context and the end of that synchronous segment */ +var restored = false; + /** * Wraps an `await` expression in such a way that the effect context that was * active before the expression evaluated can be reapplied afterwards — @@ -166,15 +167,32 @@ export function capture() { */ export async function save(promise) { var restore = capture(); + // the context restored by an earlier `save` in this expression must not + // outlive the synchronous segment that is about to end at this `await` + unsave(); var value = await promise; return () => { restore(); - queue_micro_task(unset_context); + restored = true; return value; }; } +/** + * Unset the context if a `save` thunk restored it in the current synchronous segment, + * so that a foreign microtask can never run inside a restored reaction context. + * Called at every suspension point, and at the end of async expression bodies — + * `async () => (await $.save(a))().b` becomes `async () => { try { return (await $.save(a))().b; } finally { $.unsave(); } }` + * @template T + * @param {T} [value] + * @returns {T} + */ +export function unsave(value) { + if (restored) unset_context(); + return /** @type {T} */ (value); +} + /** * Reset `current_async_effect` after the `promise` resolves, so * that we can emit `await_reactivity_loss` warnings @@ -183,6 +201,7 @@ export async function save(promise) { * @returns {Promise<() => T>} */ export async function track_reactivity_loss(promise) { + unsave(); var previous_reactivity_loss_tracker = reactivity_loss_tracker; // Ensure that unrelated reads after an async operation is kicked off don't cause false positives queueMicrotask(() => { @@ -269,6 +288,7 @@ export async function* for_await_track_reactivity_loss(iterable) { } export function unset_context(deactivate_batch = true) { + restored = false; set_active_effect(null); set_active_reaction(null); set_component_context(null); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js new file mode 100644 index 0000000000..9608e294e7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await new Promise((resolve) => setTimeout(resolve, 20)); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

pending

1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte new file mode 100644 index 0000000000..3545c69451 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte @@ -0,0 +1,14 @@ + + +{#await (await input).pending} +

pending

+{/await} + +

{foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js new file mode 100644 index 0000000000..050ee60e09 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js @@ -0,0 +1,22 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + + compileOptions: { + dev: false + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

4 1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte new file mode 100644 index 0000000000..ef69eb1154 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte @@ -0,0 +1,20 @@ + + +

{total} {foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js new file mode 100644 index 0000000000..371cca3ff3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

3 1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte new file mode 100644 index 0000000000..c85206e7f1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte @@ -0,0 +1,21 @@ + + +

{length} {foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js new file mode 100644 index 0000000000..5999daa5d2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

failed

1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte new file mode 100644 index 0000000000..e94bf86568 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte @@ -0,0 +1,26 @@ + + + {}}> + {#snippet failed()} +

failed

+ {/snippet} + +

{(await input).value}

+
+ +

{foreign}

diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js index 5c1d27ae54..b3e9e87aca 100644 --- a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js @@ -14,7 +14,19 @@ export default function Async_const($$anchor) { let b; var promises = $.run([ - async () => a = (await $.save($.async_derived(async () => (await $.save(1))())))(), + async () => { + try { + return a = (await $.save($.async_derived(async () => { + try { + return (await $.save(1))(); + } finally { + $.unsave(); + } + })))(); + } finally { + $.unsave(); + } + }, () => b = $.derived(() => $.get(a) + 1) ]); diff --git a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js index 8d21c2ceef..ca99d1c0c5 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js @@ -90,52 +90,78 @@ export default function Async_if_chain($$anchor) { var node_3 = $.sibling(node_1, 2); - $.async(node_3, [$$promises[0]], [async () => (await $.save(foo))() > 10], (node_3, $$condition) => { - var consequent_5 = ($$anchor) => { - var text_7 = $.text('foo'); - - $.append($$anchor, text_7); - }; - - var consequent_6 = ($$anchor) => { - var text_8 = $.text('bar'); - - $.append($$anchor, text_8); - }; - - var alternate_4 = ($$anchor) => { - var fragment_2 = $.comment(); - var node_4 = $.first_child(fragment_2); - - $.async(node_4, [$$promises[0]], [async () => (await $.save(foo))() > 5], (node_4, $$condition) => { - var consequent_7 = ($$anchor) => { - var text_9 = $.text('baz'); - - $.append($$anchor, text_9); - }; - - var alternate_3 = ($$anchor) => { - var text_10 = $.text('else'); - - $.append($$anchor, text_10); - }; - - $.if( + $.async( + node_3, + [$$promises[0]], + [ + async () => { + try { + return (await $.save(foo))() > 10; + } finally { + $.unsave(); + } + } + ], + (node_3, $$condition) => { + var consequent_5 = ($$anchor) => { + var text_7 = $.text('foo'); + + $.append($$anchor, text_7); + }; + + var consequent_6 = ($$anchor) => { + var text_8 = $.text('bar'); + + $.append($$anchor, text_8); + }; + + var alternate_4 = ($$anchor) => { + var fragment_2 = $.comment(); + var node_4 = $.first_child(fragment_2); + + $.async( node_4, - ($$render) => { - if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, -1); - }, - true + [$$promises[0]], + [ + async () => { + try { + return (await $.save(foo))() > 5; + } finally { + $.unsave(); + } + } + ], + (node_4, $$condition) => { + var consequent_7 = ($$anchor) => { + var text_9 = $.text('baz'); + + $.append($$anchor, text_9); + }; + + var alternate_3 = ($$anchor) => { + var text_10 = $.text('else'); + + $.append($$anchor, text_10); + }; + + $.if( + node_4, + ($$render) => { + if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, -1); + }, + true + ); + } ); - }); - $.append($$anchor, fragment_2); - }; + $.append($$anchor, fragment_2); + }; - $.if(node_3, ($$render) => { - if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, -1); - }); - }); + $.if(node_3, ($$render) => { + if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, -1); + }); + } + ); var node_5 = $.sibling(node_3, 2); 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 79b8ad0040..327dc4918c 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 @@ -34,8 +34,34 @@ export default function Async_in_derived($$anchor, $$props) { let no2; var promises = $.run([ - async () => yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(), - async () => yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(), + async () => { + try { + return yes1 = (await $.save($.async_derived(async () => { + try { + return (await $.save(1))(); + } finally { + $.unsave(); + } + })))(); + } finally { + $.unsave(); + } + }, + + async () => { + try { + return yes2 = (await $.save($.async_derived(async () => { + try { + return foo((await $.save(1))()); + } finally { + $.unsave(); + } + })))(); + } finally { + $.unsave(); + } + }, + () => no1 = $.derived(() => (async () => { return await 1; })()), From 6f5dd04a0040db09c3171afd9d5e39071db6069d Mon Sep 17 00:00:00 2001 From: Ashok Kaushik <56879395+kaushikashok45@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:45:27 +0530 Subject: [PATCH 02/27] fix: Fixes error thrown on use of select with no options (#18449) Fixes #18445. --- .changeset/every-rabbits-rule.md | 5 +++++ .../phases/3-transform/client/visitors/shared/fragment.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/every-rabbits-rule.md diff --git a/.changeset/every-rabbits-rule.md b/.changeset/every-rabbits-rule.md new file mode 100644 index 0000000000..e78df191a5 --- /dev/null +++ b/.changeset/every-rabbits-rule.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent malformed AST output for ` + + + From c894afd8c73e4f08a377909f29aa58177a256277 Mon Sep 17 00:00:00 2001 From: Jawad Ali Date: Tue, 25 Aug 2026 13:37:34 +0500 Subject: [PATCH 08/27] fix: sync SvelteURL port when protocol setter clears it (#18705) Per the WHATWG URL spec, assigning a new protocol can clear the URL's port when the current port equals the new scheme's default port. Therefore also `set` the port when the protocol is updated. --- .changeset/svelte-url-protocol-port.md | 5 +++++ packages/svelte/src/reactivity/url.js | 2 ++ packages/svelte/src/reactivity/url.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 .changeset/svelte-url-protocol-port.md diff --git a/.changeset/svelte-url-protocol-port.md b/.changeset/svelte-url-protocol-port.md new file mode 100644 index 0000000000..2c37fce4ab --- /dev/null +++ b/.changeset/svelte-url-protocol-port.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: sync `SvelteURL` port signal when the protocol setter clears the port diff --git a/packages/svelte/src/reactivity/url.js b/packages/svelte/src/reactivity/url.js index 549a20baa7..1e24e774ee 100644 --- a/packages/svelte/src/reactivity/url.js +++ b/packages/svelte/src/reactivity/url.js @@ -163,6 +163,8 @@ export class SvelteURL extends URL { set protocol(value) { super.protocol = value; set(this.#protocol, super.protocol); + // changing the protocol can clear the port when it matches the new scheme's default + set(this.#port, super.port); } get search() { diff --git a/packages/svelte/src/reactivity/url.test.ts b/packages/svelte/src/reactivity/url.test.ts index d698116421..72518d730a 100644 --- a/packages/svelte/src/reactivity/url.test.ts +++ b/packages/svelte/src/reactivity/url.test.ts @@ -240,3 +240,25 @@ test('url.searchParams.forEach re-runs when the search string changes via the UR cleanup(); }); + +test('url.port is updated when the protocol change clears the port', () => { + const url = new SvelteURL('http://example.com:443/'); + const log: any = []; + + const cleanup = effect_root(() => { + render_effect(() => { + log.push(url.port); + }); + }); + + flushSync(() => { + // 443 is the default port for https, so it gets stripped + url.protocol = 'https:'; + }); + + assert.equal(url.port, ''); + assert.equal(url.href, 'https://example.com/'); + assert.deepEqual(log, ['443', '']); + + cleanup(); +}); From 135f1ec004352048454e732202c0bc8332816242 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:24:45 +0200 Subject: [PATCH 09/27] fix: more robust rendering of Svelte custom element slots (#18710) Instead of checking the given nodes at startup and only injecting those into the inner Svelte component constructor, we are now detecting (via the `$$host` property) whether or not the component is rendered as a custom element, and in that case create all slots as real slots right away. Fixes #13638 Fixes #8997 (therefore closes #8999) --- .changeset/open-parents-stare.md | 5 ++++ .../src/internal/client/dom/blocks/slot.js | 19 +++++++++++++ .../$$slot-dynamic-content/_config.js | 8 +++--- .../html-slots/_config.js | 28 +++++++++++++++---- .../no-shadow-dom/_config.js | 3 +- .../no-shadow-dom/main.svelte | 1 + 6 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 .changeset/open-parents-stare.md diff --git a/.changeset/open-parents-stare.md b/.changeset/open-parents-stare.md new file mode 100644 index 0000000000..cb94267ea0 --- /dev/null +++ b/.changeset/open-parents-stare.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more robust rendering of Svelte custom element slots diff --git a/packages/svelte/src/internal/client/dom/blocks/slot.js b/packages/svelte/src/internal/client/dom/blocks/slot.js index 10b2959582..491f04a5ea 100644 --- a/packages/svelte/src/internal/client/dom/blocks/slot.js +++ b/packages/svelte/src/internal/client/dom/blocks/slot.js @@ -1,4 +1,6 @@ import { hydrate_next, hydrating } from '../hydration.js'; +import { create_element, create_text } from '../operations.js'; +import { append } from '../template.js'; /** * @param {Comment} anchor @@ -12,6 +14,23 @@ export function slot(anchor, $$props, name, slot_props, fallback_fn) { hydrate_next(); } + // Custom element slots are native DOM slots. + // Use the stored reference because the shadow root may be closed. + if ($$props.$$host?.$$shadowRoot) { + const element = create_element('slot'); + if (name !== 'default') element.name = name; + + append(anchor, element); + + if (fallback_fn !== null) { + const fallback_anchor = create_text(); + element.append(fallback_anchor); + fallback_fn(fallback_anchor); + } + + return; + } + var slot_fn = $$props.$$slots?.[name]; // Interop: Can use snippets to fill slots var is_interop = false; diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js index 8ade347a79..3fd5a09888 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js @@ -14,8 +14,8 @@ export default test({ assert.htmlEqual( ce.shadowRoot.innerHTML, ` - -

named fallback

+ fallback +

named fallback

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

named fallback

+ fallback +

named fallback

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

default fallback content

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

foo fallback content

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

Hello {name}!

+fallback