From 7fd2d8660fb0e9cc2f325f1cca2e99c94d418935 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:18:20 +0100 Subject: [PATCH 1/9] fix: parallelize async `@const`s in the template (#17165) * fix: parallelize async `@const`s in the template This fixes #17075 by solving the TODO of #17038 to add out of order rendering for async `@const` declarations in the template. It's implemented by a new field on the component state which is set as soon as we come across an async const. All async const declarations and those after it will be added to that field, and the existing blockers mechanism is then used to line up the async work correctly. After processing a fragment a `run` command is created from the collected consts. * fix * tweak --------- Co-authored-by: Rich Harris --- .changeset/social-taxis-tell.md | 5 ++ .changeset/stale-items-know.md | 5 ++ .../compiler/phases/1-parse/utils/create.js | 3 +- .../2-analyze/visitors/AwaitExpression.js | 4 -- .../phases/3-transform/client/types.d.ts | 5 ++ .../3-transform/client/visitors/ConstTag.js | 64 +++++++++++++++---- .../3-transform/client/visitors/Fragment.js | 19 ++---- .../client/visitors/SnippetBlock.js | 10 +-- .../client/visitors/SvelteBoundary.js | 14 +++- .../client/visitors/shared/fragment.js | 2 +- .../phases/3-transform/server/types.d.ts | 13 +++- .../3-transform/server/visitors/ConstTag.js | 26 +++++++- .../3-transform/server/visitors/EachBlock.js | 8 +-- .../3-transform/server/visitors/Fragment.js | 9 ++- .../3-transform/server/visitors/IfBlock.js | 6 +- .../server/visitors/SnippetBlock.js | 5 -- .../server/visitors/SvelteBoundary.js | 13 +--- .../server/visitors/shared/component.js | 7 +- .../svelte/src/compiler/types/template.d.ts | 2 - .../samples/async-const/_config.js | 5 +- .../samples/async-const/main.svelte | 10 +-- .../_expected/client/index.svelte.js | 23 ++++--- .../_expected/server/index.svelte.js | 50 +++++++++------ 23 files changed, 191 insertions(+), 117 deletions(-) create mode 100644 .changeset/social-taxis-tell.md create mode 100644 .changeset/stale-items-know.md diff --git a/.changeset/social-taxis-tell.md b/.changeset/social-taxis-tell.md new file mode 100644 index 0000000000..ea23a01def --- /dev/null +++ b/.changeset/social-taxis-tell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure async `@const` in boundary hydrates correctly diff --git a/.changeset/stale-items-know.md b/.changeset/stale-items-know.md new file mode 100644 index 0000000000..60fa85f595 --- /dev/null +++ b/.changeset/stale-items-know.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: parallelize async `@const`s in the template diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/create.js b/packages/svelte/src/compiler/phases/1-parse/utils/create.js index 2fba918f20..6030f1bd7b 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/create.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/create.js @@ -10,8 +10,7 @@ export function create_fragment(transparent = false) { nodes: [], metadata: { transparent, - dynamic: false, - has_await: false + dynamic: false } }; } 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 22a89db76e..545bc3be27 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -26,10 +26,6 @@ export function AwaitExpression(node, context) { if (context.state.expression) { context.state.expression.has_await = true; - if (context.state.fragment && context.path.some((node) => node.type === 'ConstTag')) { - context.state.fragment.metadata.has_await = true; - } - suspend = true; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 410ad120d7..d64b1d4126 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -51,6 +51,11 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly after_update: Statement[]; /** Transformed `{@const }` declarations */ readonly consts: Statement[]; + /** Transformed async `{@const }` declarations (if any) and those coming after them */ + async_consts?: { + id: Identifier; + thunks: Expression[]; + }; /** Transformed `let:` directives */ readonly let_directives: Statement[]; /** Memoized expressions */ 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 81dd9e07ed..f3d7a3549c 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 @@ -24,15 +24,15 @@ export function ConstTag(node, context) { expression = b.call('$.tag', expression, b.literal(declaration.id.name)); } - context.state.consts.push(b.const(declaration.id, expression)); - context.state.transform[declaration.id.name] = { read: get_value }; - // we need to eagerly evaluate the expression in order to hit any - // 'Cannot access x before initialization' errors - if (dev) { - context.state.consts.push(b.stmt(b.call('$.get', declaration.id))); - } + add_const_declaration( + context.state, + declaration.id, + expression, + node.metadata.expression.has_await, + context.state.scope.get_bindings(declaration) + ); } else { const identifiers = extract_identifiers(declaration.id); const tmp = b.id(context.state.scope.generate('computed_const')); @@ -69,13 +69,13 @@ export function ConstTag(node, context) { expression = b.call('$.tag', expression, b.literal('[@const]')); } - context.state.consts.push(b.const(tmp, expression)); - - // we need to eagerly evaluate the expression in order to hit any - // 'Cannot access x before initialization' errors - if (dev) { - context.state.consts.push(b.stmt(b.call('$.get', tmp))); - } + add_const_declaration( + context.state, + tmp, + expression, + node.metadata.expression.has_await, + context.state.scope.get_bindings(declaration) + ); for (const node of identifiers) { context.state.transform[node.name] = { @@ -84,3 +84,39 @@ export function ConstTag(node, context) { } } } + +/** + * @param {ComponentContext['state']} state + * @param {import('estree').Identifier} id + * @param {import('estree').Expression} expression + * @param {boolean} has_await + * @param {import('#compiler').Binding[]} bindings + */ +function add_const_declaration(state, id, expression, has_await, bindings) { + // 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))] : []; + + if (has_await || state.async_consts) { + const run = (state.async_consts ??= { + id: b.id(state.scope.generate('promises')), + 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]); + + 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; + } + } else { + state.consts.push(b.const(id, expression)); + state.consts.push(...after); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 8d6a2fac88..ff2436779b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -48,8 +48,6 @@ export function Fragment(node, context) { const is_single_child_not_needing_template = trimmed.length === 1 && (trimmed[0].type === 'SvelteFragment' || trimmed[0].type === 'TitleElement'); - const has_await = context.state.init !== null && (node.metadata.has_await || false); - const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent /** @type {Statement[]} */ @@ -72,7 +70,8 @@ export function Fragment(node, context) { metadata: { namespace, bound_contenteditable: context.state.metadata.bound_contenteditable - } + }, + async_consts: undefined }; for (const node of hoisted) { @@ -153,8 +152,8 @@ export function Fragment(node, context) { body.push(...state.let_directives, ...state.consts); - if (has_await) { - body.push(b.if(b.call('$.aborted'), b.return())); + if (state.async_consts && state.async_consts.thunks.length > 0) { + body.push(b.var(state.async_consts.id, b.call('$.run', b.array(state.async_consts.thunks)))); } if (is_text_first) { @@ -177,13 +176,5 @@ export function Fragment(node, context) { body.push(close); } - if (has_await) { - return b.block([ - b.stmt( - b.call('$.async_body', b.id('$$anchor'), b.arrow([b.id('$$anchor')], b.block(body), true)) - ) - ]); - } else { - return b.block(body); - } + return b.block(body); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js index 895522d47a..1af737f05b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js @@ -14,8 +14,6 @@ export function SnippetBlock(node, context) { // TODO hoist where possible /** @type {(Identifier | AssignmentPattern)[]} */ const args = [b.id('$$anchor')]; - const has_await = node.body.metadata.has_await || false; - /** @type {BlockStatement} */ let body; @@ -78,12 +76,8 @@ export function SnippetBlock(node, context) { // in dev we use a FunctionExpression (not arrow function) so we can use `arguments` let snippet = dev - ? b.call( - '$.wrap_snippet', - b.id(context.state.analysis.name), - b.function(null, args, body, has_await) - ) - : b.arrow(args, body, has_await); + ? b.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body)) + : b.arrow(args, body); const declaration = b.const(node.expression, snippet); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index 49c89bc438..d64fcda2e8 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -48,7 +48,11 @@ export function SvelteBoundary(node, context) { if (child.type === 'ConstTag') { has_const = true; if (!context.state.options.experimental.async) { - context.visit(child, { ...context.state, consts: const_tags }); + context.visit(child, { + ...context.state, + consts: const_tags, + scope: context.state.scopes.get(node.fragment) ?? context.state.scope + }); } } } @@ -101,7 +105,13 @@ export function SvelteBoundary(node, context) { nodes.push(child); } - const block = /** @type {BlockStatement} */ (context.visit({ ...node.fragment, nodes })); + const block = /** @type {BlockStatement} */ ( + context.visit( + { ...node.fragment, nodes }, + // Since we're creating a new fragment the reference in scopes can't match, so we gotta attach the right scope manually + { ...context.state, scope: context.state.scopes.get(node.fragment) ?? context.state.scope } + ) + ); if (!context.state.options.experimental.async) { block.body.unshift(...const_tags); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index c7f843af48..67982b6150 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -105,7 +105,7 @@ export function process_children(nodes, initial, is_element, context) { is_element && // In case it's wrapped in async the async logic will want to skip sibling nodes up until the end, hence we cannot make this controlled // TODO switch this around and instead optimize for elements with a single block child and not require extra comments (neither for async nor normally) - !(node.body.metadata.has_await || node.metadata.expression.is_async()) + !node.metadata.expression.is_async() ) { node.metadata.is_controlled = true; } else { diff --git a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts index adde7480cb..e7a72fb8ad 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts @@ -1,4 +1,10 @@ -import type { Expression, Statement, ModuleDeclaration, LabeledStatement } from 'estree'; +import type { + Expression, + Statement, + ModuleDeclaration, + LabeledStatement, + Identifier +} from 'estree'; import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; import type { ComponentAnalysis } from '../../types.js'; @@ -21,6 +27,11 @@ export interface ComponentServerTransformState extends ServerTransformState { readonly namespace: Namespace; readonly preserve_whitespace: boolean; readonly skip_hydration_boundaries: boolean; + /** Transformed async `{@const }` declarations (if any) and those coming after them */ + async_consts?: { + id: Identifier; + thunks: Expression[]; + }; } export type Context = import('zimmerframe').Context; 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 a8e4e575cc..c549d1d009 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 @@ -2,6 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; +import { extract_identifiers } from '../../../../utils/ast.js'; /** * @param {AST.ConstTag} node @@ -11,6 +12,29 @@ 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; - context.state.init.push(b.const(id, init)); + if (has_await || context.state.async_consts) { + const run = (context.state.async_consts ??= { + id: b.id(context.state.scope.generate('promises')), + 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)); + } + + 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; + } + } else { + context.state.init.push(b.const(id, init)); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js index f53a3903c2..3c0a8c1676 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js @@ -34,11 +34,7 @@ export function EachBlock(node, context) { const new_body = /** @type {BlockStatement} */ (context.visit(node.body)).body; - if (node.body) - each.push( - // TODO get rid of fragment.has_await - ...(node.body.metadata.has_await ? [create_async_block(b.block(new_body))] : new_body) - ); + if (node.body) each.push(...new_body); const for_loop = b.for( b.declaration('let', [ @@ -61,7 +57,7 @@ export function EachBlock(node, context) { b.if( b.binary('!==', b.member(array_id, 'length'), b.literal(0)), b.block([open, for_loop]), - node.fallback.metadata.has_await ? create_async_block(fallback) : fallback + fallback ) ); } else { diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js index a1d25980c4..ef5bd985ae 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js @@ -28,7 +28,8 @@ export function Fragment(node, context) { init: [], template: [], namespace, - skip_hydration_boundaries: is_standalone + skip_hydration_boundaries: is_standalone, + async_consts: undefined }; for (const node of hoisted) { @@ -42,5 +43,11 @@ export function Fragment(node, context) { process_children(trimmed, { ...context, state }); + if (state.async_consts && state.async_consts.thunks.length > 0) { + state.init.push( + b.var(state.async_consts.id, b.call('$$renderer.run', b.array(state.async_consts.thunks))) + ); + } + return b.block([...state.init, ...build_template(state.template)]); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js index ca614a93e2..e8418343be 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js @@ -25,11 +25,7 @@ export function IfBlock(node, context) { const is_async = node.metadata.expression.is_async(); - const has_await = - node.metadata.expression.has_await || - // TODO get rid of this stuff - node.consequent.metadata.has_await || - node.alternate?.metadata.has_await; + const has_await = node.metadata.expression.has_await; if (is_async || has_await) { statement = create_async_block( diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js index 5fc865ec58..7ae2a8e037 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js @@ -3,7 +3,6 @@ /** @import { ComponentContext } from '../types.js' */ import { dev } from '../../../../state.js'; import * as b from '#compiler/builders'; -import { create_async_block } from './shared/utils.js'; /** * @param {AST.SnippetBlock} node @@ -16,10 +15,6 @@ export function SnippetBlock(node, context) { /** @type {BlockStatement} */ (context.visit(node.body)) ); - if (node.body.metadata.has_await) { - fn.body = b.block([create_async_block(fn.body)]); - } - // @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone fn.___snippet = true; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteBoundary.js index 45f1b5aad2..8a30e765c2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteBoundary.js @@ -7,8 +7,7 @@ import { block_open, block_open_else, build_attribute_value, - build_template, - create_async_block + build_template } from './shared/utils.js'; /** @@ -43,14 +42,11 @@ export function SvelteBoundary(node, context) { ); const pending = b.call(callee, b.id('$$renderer')); const block = /** @type {BlockStatement} */ (context.visit(node.fragment)); - const statement = node.fragment.metadata.has_await - ? create_async_block(b.block([block])) - : block; context.state.template.push( b.if( callee, b.block(build_template([block_open_else, b.stmt(pending), block_close])), - b.block(build_template([block_open, statement, block_close])) + b.block(build_template([block_open, block, block_close])) ) ); } else { @@ -70,9 +66,6 @@ export function SvelteBoundary(node, context) { } } else { const block = /** @type {BlockStatement} */ (context.visit(node.fragment)); - const statement = node.fragment.metadata.has_await - ? create_async_block(b.block([block])) - : block; - context.state.template.push(block_open, statement, block_close); + context.state.template.push(block_open, block, block_close); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index 2e7b4a186c..6f2ff38bc1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -244,12 +244,7 @@ export function build_inline_component(node, expression, context) { params.push(pattern); } - const slot_fn = b.arrow( - params, - node.fragment.metadata.has_await - ? b.block([create_async_block(b.block(block.body))]) - : b.block(block.body) - ); + const slot_fn = b.arrow(params, b.block(block.body)); if (slot_name === 'default' && !has_children_prop) { if ( diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 3960c95c8f..fd664f107c 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -48,8 +48,6 @@ export namespace AST { * Whether or not we need to traverse into the fragment during mount/hydrate */ dynamic: boolean; - /** @deprecated we should get rid of this in favour of the `$$renderer.run` mechanism */ - has_await: boolean; }; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js index 8aeca875f3..c3e74e886a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-const/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js @@ -2,11 +2,12 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - html: `

Loading...

`, + mode: ['async-server', 'client', 'hydrate'], + ssrHtml: `

Hello, world!

5 01234 5 sync 6 5 0`, async test({ assert, target }) { await tick(); - assert.htmlEqual(target.innerHTML, `

Hello, world!

5 01234`); + assert.htmlEqual(target.innerHTML, `

Hello, world!

5 01234 5 sync 6 5 0`); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte index 7410ff6a6f..b7e00803c5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte @@ -3,17 +3,16 @@ + {@const sync = 'sync'} {@const number = await Promise.resolve(5)} - - {#snippet pending()} -

Loading...

- {/snippet} + {@const after_async = number + 1} + {@const { length, 0: first } = await '01234'} {#snippet greet()} {@const greeting = await `Hello, ${name}!`}

{greeting}

{number} - {#if number > 4} + {#if number > 4 && after_async && greeting} {@const length = await number} {#each { length }, index} {@const i = await index} @@ -23,4 +22,5 @@ {/snippet} {@render greet()} + {number} {sync} {after_async} {length} {first}
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 e4df43c6c2..7d1fe4ec67 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 @@ -25,20 +25,23 @@ export default function Async_in_derived($$anchor, $$props) { { var consequent = ($$anchor) => { - $.async_body($$anchor, async ($$anchor) => { - const yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(); - const yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(); + let yes1; + let yes2; + let no1; + let no2; - const no1 = $.derived(() => (async () => { - return await 1; - })()); + var promises = $.run([ + async () => yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(), + async () => yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(), - const no2 = $.derived(() => (async () => { + () => no1 = $.derived(() => (async () => { return await 1; - })()); + })()), - if ($.aborted()) return; - }); + () => no2 = $.derived(() => (async () => { + return await 1; + })()) + ]); }; $.if(node, ($$render) => { 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 bece6402c6..1fd184fa79 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 @@ -18,24 +18,38 @@ export default function Async_in_derived($$renderer, $$props) { } ]); - $$renderer.async_block([], async ($$renderer) => { - if (true) { - $$renderer.push(''); - - const yes1 = (await $.save(1))(); - const yes2 = foo((await $.save(1))()); - - const no1 = (async () => { - return await 1; - })(); - - const no2 = (async () => { - return await 1; - })(); - } else { - $$renderer.push(''); - } - }); + if (true) { + $$renderer.push(''); + + let yes1; + let yes2; + let no1; + 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; + })(); + } + ]); + } else { + $$renderer.push(''); + } $$renderer.push(``); }); From 686720070be0f9ace0a4efe735a0b5a6e11dd012 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:26:18 +0100 Subject: [PATCH 2/9] fix: keep deriveds reactive after their original parent effect was destroyed (#17171) Use case: Remote queries that are created on one screen, then are used again on another screen. Original parent effect is destroyed in that case but derived should still be reactive. It wasn't prior to this fix because inside `get` the `destroyed` variable would be true and so deps would not properly be recorded. Fixes https://github.com/sveltejs/kit/issues/14814 --- .changeset/sad-forks-go.md | 5 ++++ .../internal/client/reactivity/deriveds.js | 7 +++-- packages/svelte/tests/signals/test.ts | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .changeset/sad-forks-go.md diff --git a/.changeset/sad-forks-go.md b/.changeset/sad-forks-go.md new file mode 100644 index 0000000000..da27e11642 --- /dev/null +++ b/.changeset/sad-forks-go.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep deriveds reactive after their original parent effect was destroyed diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7e6f3c6f60..070230a461 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -11,7 +11,8 @@ import { STALE_REACTION, ASYNC, WAS_MARKED, - CONNECTED + CONNECTED, + DESTROYED } from '#client/constants'; import { active_reaction, @@ -296,7 +297,9 @@ function get_derived_parent_effect(derived) { var parent = derived.parent; while (parent !== null) { if ((parent.f & DERIVED) === 0) { - return /** @type {Effect} */ (parent); + // 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; } diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index eff6d6166a..13430609a8 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1391,6 +1391,33 @@ describe('signals', () => { }; }); + test('derived whose original parent effect has been destroyed keeps updating', () => { + return () => { + let count: Source; + let double: Derived; + const destroy = effect_root(() => { + render_effect(() => { + count = state(0); + double = derived(() => $.get(count) * 2); + }); + }); + + flushSync(); + assert.equal($.get(double!), 0); + + destroy(); + flushSync(); + + set(count!, 1); + flushSync(); + assert.equal($.get(double!), 2); + + set(count!, 2); + flushSync(); + assert.equal($.get(double!), 4); + }; + }); + test('$effect.root inside deriveds stay alive independently', () => { const log: any[] = []; const c = state(0); From e0501ede035c459c6fdad4dba79b075c64a1d3d3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 17 Nov 2025 14:09:12 -0500 Subject: [PATCH 3/9] chore: only skip effects when deferring (#17169) * chore: only skip effects when deferring * more --- .../src/internal/client/dom/blocks/each.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 3f12593d01..0eb8f889c3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -244,7 +244,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f item.i = i; } - batch.skipped_effects.delete(item.e); + if (defer) { + batch.skipped_effects.delete(item.e); + } } else { item = create_item( first_run ? anchor : null, @@ -298,14 +300,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f set_hydrate_node(skip_nodes()); } - for (const [key, item] of state.items) { - if (!keys.has(key)) { - batch.skipped_effects.add(item.e); - } - } - if (!first_run) { if (defer) { + for (const [key, item] of state.items) { + if (!keys.has(key)) { + batch.skipped_effects.add(item.e); + } + } + batch.oncommit(commit); batch.ondiscard(() => { // TODO presumably we need to do something here? From a7a6d898d56cc0ff0518d0e744d23cb76c2ae1c3 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:58:41 +0100 Subject: [PATCH 4/9] fix: correctly handle functions when determining async blockers (#17137) * fix: correctly handle functions when determining async blockers We didn't properly handle functions (function declarations/expressions/arrow functions) when calculating what is a blocker. More specifically - we did defer assignment of variable declarations even for arrow functions and function expressions, which is unnecessary and causes bugs when they're then referenced eagerly further below - we did not compute blockers for functions. They could reference blockers themselves, as such other code referencing them should wait on the related blockers Fixes #17129 * put into its own function * fix: take blockers into account when creating `#await` blocks The unrelated-but-in-the-same-issue-referenced-bug * oops * fix * minimize compiled output changes * no idea why editor showed these as unused --- .changeset/gentle-showers-speak.md | 5 + .changeset/soft-radios-make.md | 5 + .../src/compiler/phases/2-analyze/index.js | 456 +++++++++++------- .../3-transform/client/visitors/AwaitBlock.js | 44 +- packages/svelte/src/compiler/phases/nodes.js | 4 + packages/svelte/src/compiler/phases/scope.js | 12 +- .../svelte/src/compiler/utils/builders.js | 2 +- .../samples/async-await-block/_config.js | 9 + .../samples/async-await-block/main.svelte | 7 + .../async-indirect-blockers/Component1.svelte | 13 + .../async-indirect-blockers/Component2.svelte | 11 + .../async-indirect-blockers/Component3.svelte | 16 + .../async-indirect-blockers/Component4.svelte | 19 + .../async-indirect-blockers/_config.js | 27 ++ .../async-indirect-blockers/main.svelte | 11 + 15 files changed, 434 insertions(+), 207 deletions(-) create mode 100644 .changeset/gentle-showers-speak.md create mode 100644 .changeset/soft-radios-make.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-await-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-await-block/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component1.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component2.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component3.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component4.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/main.svelte diff --git a/.changeset/gentle-showers-speak.md b/.changeset/gentle-showers-speak.md new file mode 100644 index 0000000000..9cc1adee94 --- /dev/null +++ b/.changeset/gentle-showers-speak.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly handle functions when determining async blockers diff --git a/.changeset/soft-radios-make.md b/.changeset/soft-radios-make.md new file mode 100644 index 0000000000..15a9dbe35a --- /dev/null +++ b/.changeset/soft-radios-make.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: take blockers into account when creating `#await` blocks diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index ed71b898ed..34490ff9c8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -687,193 +687,7 @@ export function analyze_component(root, source, options) { } } - /** - * @param {ESTree.Node} expression - * @param {Scope} scope - * @param {Set} touched - * @param {Set} seen - */ - const touch = (expression, scope, touched, seen = new Set()) => { - if (seen.has(expression)) return; - seen.add(expression); - - walk( - expression, - { scope }, - { - ImportDeclaration(node) {}, - Identifier(node, context) { - const parent = /** @type {ESTree.Node} */ (context.path.at(-1)); - if (is_reference(node, parent)) { - const binding = context.state.scope.get(node.name); - if (binding) { - touched.add(binding); - - for (const assignment of binding.assignments) { - touch(assignment.value, assignment.scope, touched, seen); - } - } - } - } - } - ); - }; - - /** - * @param {ESTree.Node} node - * @param {Set} seen - * @param {Set} reads - * @param {Set} writes - */ - const trace_references = (node, reads, writes, seen = new Set()) => { - if (seen.has(node)) return; - seen.add(node); - - /** - * @param {ESTree.Pattern} node - * @param {Scope} scope - */ - function update(node, scope) { - for (const pattern of unwrap_pattern(node)) { - const node = object(pattern); - if (!node) return; - - const binding = scope.get(node.name); - if (!binding) return; - - writes.add(binding); - } - } - - walk( - node, - { scope: instance.scope }, - { - _(node, context) { - const scope = scopes.get(node); - if (scope) { - context.next({ scope }); - } else { - context.next(); - } - }, - AssignmentExpression(node, context) { - update(node.left, context.state.scope); - }, - UpdateExpression(node, context) { - update( - /** @type {ESTree.Identifier | ESTree.MemberExpression} */ (node.argument), - context.state.scope - ); - }, - CallExpression(node, context) { - // for now, assume everything touched by the callee ends up mutating the object - // TODO optimise this better - - // special case — no need to peek inside effects as they only run once async work has completed - const rune = get_rune(node, context.state.scope); - if (rune === '$effect') return; - - /** @type {Set} */ - const touched = new Set(); - touch(node, context.state.scope, touched); - - for (const b of touched) { - writes.add(b); - } - }, - // don't look inside functions until they are called - ArrowFunctionExpression(_, context) {}, - FunctionDeclaration(_, context) {}, - FunctionExpression(_, context) {}, - Identifier(node, context) { - const parent = /** @type {ESTree.Node} */ (context.path.at(-1)); - if (is_reference(node, parent)) { - const binding = context.state.scope.get(node.name); - if (binding) { - reads.add(binding); - } - } - } - } - ); - }; - - let awaited = false; - - // TODO this should probably be attached to the scope? - var promises = b.id('$$promises'); - - /** - * @param {ESTree.Identifier} id - * @param {ESTree.Expression} blocker - */ - function push_declaration(id, blocker) { - analysis.instance_body.declarations.push(id); - - const binding = /** @type {Binding} */ (instance.scope.get(id.name)); - binding.blocker = blocker; - } - - for (let node of instance.ast.body) { - if (node.type === 'ImportDeclaration') { - analysis.instance_body.hoisted.push(node); - continue; - } - - if (node.type === 'ExportDefaultDeclaration' || node.type === 'ExportAllDeclaration') { - // these can't exist inside ` + +{#await foo then x} +

{x}

+{/await} diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component1.svelte b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component1.svelte new file mode 100644 index 0000000000..a3bb9d92b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component1.svelte @@ -0,0 +1,13 @@ + + + +

{getValue()}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component2.svelte b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component2.svelte new file mode 100644 index 0000000000..8b28cf5708 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component2.svelte @@ -0,0 +1,11 @@ + + + +

{getValue()}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component3.svelte b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component3.svelte new file mode 100644 index 0000000000..f26daeb4f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component3.svelte @@ -0,0 +1,16 @@ + + + +

{getValue()}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component4.svelte b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component4.svelte new file mode 100644 index 0000000000..564cb2660a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/Component4.svelte @@ -0,0 +1,19 @@ + + + +

{getValue()}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/_config.js b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/_config.js new file mode 100644 index 0000000000..7b7ee5b122 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['async-server', 'client', 'hydrate'], + ssrHtml: + '

', + + async test({ assert, target }) { + await tick(); + + const inputs = Array.from(target.querySelectorAll('input')); + const paragraphs = Array.from(target.querySelectorAll('p')); + + for (let i = 0; i < 4; i++) { + assert.equal(inputs[i].value, ''); + assert.htmlEqual(paragraphs[i].innerHTML, ''); + + inputs[i].value = 'hello'; + inputs[i].dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.equal(inputs[i].value, 'hello'); + assert.htmlEqual(paragraphs[i].innerHTML, 'hello'); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/main.svelte new file mode 100644 index 0000000000..c30111fd2b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-indirect-blockers/main.svelte @@ -0,0 +1,11 @@ + + + + + + From 99e670f63297ca86831cd4d786ef2adf8fdca5a4 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 17 Nov 2025 23:00:00 +0100 Subject: [PATCH 5/9] fix: ensure eager effects don't break reactions chain (#17138) Execution of eager effects didn't set `is_updating_effect`, which meant the logic in `get` would wrongfully prevent dependencies being added to `reactions` of sources/deriveds. Fixes #17133 --- .changeset/silent-teeth-invent.md | 5 +++ .../src/internal/client/reactivity/sources.js | 26 +++++++++----- .../samples/inspect-derived-4/_config.js | 35 +++++++++++++++++++ .../samples/inspect-derived-4/main.svelte | 14 ++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 .changeset/silent-teeth-invent.md create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-4/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-4/main.svelte diff --git a/.changeset/silent-teeth-invent.md b/.changeset/silent-teeth-invent.md new file mode 100644 index 0000000000..54603cc27e --- /dev/null +++ b/.changeset/silent-teeth-invent.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure eager effects don't break reactions chain diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 8ae406b57b..052ca97dc0 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -14,7 +14,9 @@ import { is_dirty, untracking, is_destroying_effect, - push_reaction_value + push_reaction_value, + set_is_updating_effect, + is_updating_effect } from '../runtime.js'; import { equals, safe_equals } from './equality.js'; import { @@ -246,19 +248,25 @@ export function internal_set(source, value) { export function flush_eager_effects() { eager_effects_deferred = false; + var prev_is_updating_effect = is_updating_effect; + set_is_updating_effect(true); const inspects = Array.from(eager_effects); - for (const effect of inspects) { - // Mark clean inspect-effects as maybe dirty and then check their dirtiness - // instead of just updating the effects - this way we avoid overfiring. - if ((effect.f & CLEAN) !== 0) { - set_signal_status(effect, MAYBE_DIRTY); - } + try { + for (const effect of inspects) { + // Mark clean inspect-effects as maybe dirty and then check their dirtiness + // instead of just updating the effects - this way we avoid overfiring. + if ((effect.f & CLEAN) !== 0) { + set_signal_status(effect, MAYBE_DIRTY); + } - if (is_dirty(effect)) { - update_effect(effect); + if (is_dirty(effect)) { + update_effect(effect); + } } + } finally { + set_is_updating_effect(prev_is_updating_effect); } eager_effects.clear(); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/_config.js new file mode 100644 index 0000000000..3a3bca7221 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/_config.js @@ -0,0 +1,35 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_inspect_logs } from '../../../helpers'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, logs }) { + const [b] = target.querySelectorAll('button'); + + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ``); + + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ``); + + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(normalise_inspect_logs(logs), [ + [0, 1, 2], + [1, 2], + 'at SvelteSet.add', + [2], + 'at SvelteSet.add', + [], + 'at SvelteSet.add' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/main.svelte new file mode 100644 index 0000000000..eb4ea891db --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-4/main.svelte @@ -0,0 +1,14 @@ + + + From b94289d23b593aa0991811977d2ad4cc8891805d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:43:22 -0500 Subject: [PATCH 6/9] Version Packages (#17168) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/gentle-showers-speak.md | 5 ----- .changeset/sad-forks-go.md | 5 ----- .changeset/silent-teeth-invent.md | 5 ----- .changeset/social-taxis-tell.md | 5 ----- .changeset/soft-radios-make.md | 5 ----- .changeset/stale-items-know.md | 5 ----- packages/svelte/CHANGELOG.md | 16 ++++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 9 files changed, 18 insertions(+), 32 deletions(-) delete mode 100644 .changeset/gentle-showers-speak.md delete mode 100644 .changeset/sad-forks-go.md delete mode 100644 .changeset/silent-teeth-invent.md delete mode 100644 .changeset/social-taxis-tell.md delete mode 100644 .changeset/soft-radios-make.md delete mode 100644 .changeset/stale-items-know.md diff --git a/.changeset/gentle-showers-speak.md b/.changeset/gentle-showers-speak.md deleted file mode 100644 index 9cc1adee94..0000000000 --- a/.changeset/gentle-showers-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: correctly handle functions when determining async blockers diff --git a/.changeset/sad-forks-go.md b/.changeset/sad-forks-go.md deleted file mode 100644 index da27e11642..0000000000 --- a/.changeset/sad-forks-go.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: keep deriveds reactive after their original parent effect was destroyed diff --git a/.changeset/silent-teeth-invent.md b/.changeset/silent-teeth-invent.md deleted file mode 100644 index 54603cc27e..0000000000 --- a/.changeset/silent-teeth-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure eager effects don't break reactions chain diff --git a/.changeset/social-taxis-tell.md b/.changeset/social-taxis-tell.md deleted file mode 100644 index ea23a01def..0000000000 --- a/.changeset/social-taxis-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure async `@const` in boundary hydrates correctly diff --git a/.changeset/soft-radios-make.md b/.changeset/soft-radios-make.md deleted file mode 100644 index 15a9dbe35a..0000000000 --- a/.changeset/soft-radios-make.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: take blockers into account when creating `#await` blocks diff --git a/.changeset/stale-items-know.md b/.changeset/stale-items-know.md deleted file mode 100644 index 60fa85f595..0000000000 --- a/.changeset/stale-items-know.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: parallelize async `@const`s in the template diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 1fd67f455d..0c8b8b4d18 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,21 @@ # svelte +## 5.43.9 + +### Patch Changes + +- fix: correctly handle functions when determining async blockers ([#17137](https://github.com/sveltejs/svelte/pull/17137)) + +- fix: keep deriveds reactive after their original parent effect was destroyed ([#17171](https://github.com/sveltejs/svelte/pull/17171)) + +- fix: ensure eager effects don't break reactions chain ([#17138](https://github.com/sveltejs/svelte/pull/17138)) + +- fix: ensure async `@const` in boundary hydrates correctly ([#17165](https://github.com/sveltejs/svelte/pull/17165)) + +- fix: take blockers into account when creating `#await` blocks ([#17137](https://github.com/sveltejs/svelte/pull/17137)) + +- fix: parallelize async `@const`s in the template ([#17165](https://github.com/sveltejs/svelte/pull/17165)) + ## 5.43.8 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index dfecc8d62d..8b9d1a1eae 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.43.8", + "version": "5.43.9", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 191a36da84..8453735367 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.43.8'; +export const VERSION = '5.43.9'; export const PUBLIC_VERSION = '5'; From fe50e58f34b7df263b1875e495536fe87905e75f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 17 Nov 2025 18:57:00 -0500 Subject: [PATCH 7/9] chore: fix types of binding.blocker (#17177) --- packages/svelte/src/compiler/phases/2-analyze/index.js | 10 +++++++++- packages/svelte/src/compiler/phases/scope.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 34490ff9c8..4206f3df9a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -1184,7 +1184,15 @@ function calculate_blockers(instance, scopes, analysis) { trace_references(body, reads_writes, reads_writes); const max = [...reads_writes].reduce((max, binding) => { - return binding.blocker ? Math.max(binding.blocker.property.value, max) : max; + if (binding.blocker) { + let property = /** @type {ESTree.SimpleLiteral & { value: number }} */ ( + binding.blocker.property + ); + + return Math.max(property.value, max); + } + + return max; }, -1); if (max === -1) continue; diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 94b4994bc5..2a0c76f756 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -141,7 +141,7 @@ export class Binding { * otherwise the initial value will not have been assigned. * It is a member expression of the form `$$blockers[n]`. * TODO the blocker is set during transform which feels a bit grubby - * @type {MemberExpression & { object: Identifier, property: SimpleLiteral & { value: number } } | null} + * @type {MemberExpression | null} */ blocker = null; From 3604fdac6bedb09e26dca250e757543910c0ed22 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 18 Nov 2025 01:19:31 +0100 Subject: [PATCH 8/9] fix: avoid other batches running with queued root effects of main batch (#17145) * fix: avoid other batches running with queued root effects of main batch When `#traverse_effect_tree` runs, it can execute block effects, which in turn can create effects which are scheduled, which means `queued_root_effects` is now filled again. We didn't take that into account and assumed that in `#commit` we would have no queued root effects. As a result another batch could wrongfully run with the queued root effects of the main batch. That in turn can mean that `skipped_effects` is different on the other batch, leading to some branches not getting traversed into. As a result part of the tree is marked clean while further below batches are still not marked clean. That then breaks reactivity as soon as you schedule an effect in this still-not-clean sub tree, as it will not bubble all the way up to the root, since it comes across a not-clean branch, assuming something was already scheduled. The fix is simple: Stash the queued root effects before rebasing branches. Fixes #17118 * add note to self --------- Co-authored-by: Rich Harris --- .changeset/sixty-glasses-try.md | 5 ++ .../src/internal/client/reactivity/batch.js | 10 ++- .../async-block-effect-queueing/A.svelte | 15 +++++ .../async-block-effect-queueing/B.svelte | 1 + .../async-block-effect-queueing/_config.js | 63 +++++++++++++++++++ .../async-block-effect-queueing/main.svelte | 24 +++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .changeset/sixty-glasses-try.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/A.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/B.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/main.svelte diff --git a/.changeset/sixty-glasses-try.md b/.changeset/sixty-glasses-try.md new file mode 100644 index 0000000000..77db4a5cc5 --- /dev/null +++ b/.changeset/sixty-glasses-try.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid other batches running with queued root effects of main batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 03a0721057..c7e01fbcba 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -68,6 +68,7 @@ export let previous_batch = null; */ export let batch_values = null; +// TODO this should really be a property of `batch` /** @type {Effect[]} */ let queued_root_effects = []; @@ -171,6 +172,8 @@ export class Batch { for (const root of root_effects) { this.#traverse_effect_tree(root, target); + // Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects, + // which means queued_root_effects now may be filled again. } if (!this.is_fork) { @@ -418,6 +421,10 @@ export class Batch { // Re-run async/block effects that depend on distinct values changed in both batches const others = [...batch.current.keys()].filter((s) => !this.current.has(s)); if (others.length > 0) { + // Avoid running queued root effects on the wrong branch + var prev_queued_root_effects = queued_root_effects; + queued_root_effects = []; + /** @type {Set} */ const marked = new Set(); /** @type {Map} */ @@ -436,9 +443,10 @@ export class Batch { // TODO do we need to do anything with `target`? defer block effects? - queued_root_effects = []; batch.deactivate(); } + + queued_root_effects = prev_queued_root_effects; } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/A.svelte b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/A.svelte new file mode 100644 index 0000000000..7971deff5f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/A.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/B.svelte b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/B.svelte new file mode 100644 index 0000000000..7371f47a6f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/B.svelte @@ -0,0 +1 @@ +B \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/_config.js b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/_config.js new file mode 100644 index 0000000000..789cdfaa02 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/_config.js @@ -0,0 +1,63 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [fork, commit, toggle] = target.querySelectorAll('button'); + + fork.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + toggle.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + toggle.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + toggle.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + commit.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + B + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/main.svelte new file mode 100644 index 0000000000..7342a37f05 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-effect-queueing/main.svelte @@ -0,0 +1,24 @@ + + + + + + +{#if open} + +{:else} + +{/if} From 3b4b0adcd5b7d1c48f4f2eb45b30a8471484b2a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:21:57 -0500 Subject: [PATCH 9/9] Version Packages (#17178) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/sixty-glasses-try.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/sixty-glasses-try.md diff --git a/.changeset/sixty-glasses-try.md b/.changeset/sixty-glasses-try.md deleted file mode 100644 index 77db4a5cc5..0000000000 --- a/.changeset/sixty-glasses-try.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid other batches running with queued root effects of main batch diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 0c8b8b4d18..670940f2df 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.43.10 + +### Patch Changes + +- fix: avoid other batches running with queued root effects of main batch ([#17145](https://github.com/sveltejs/svelte/pull/17145)) + ## 5.43.9 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 8b9d1a1eae..bec65bf787 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.43.9", + "version": "5.43.10", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 8453735367..44ac4393ed 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.43.9'; +export const VERSION = '5.43.10'; export const PUBLIC_VERSION = '5';