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

{message}

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

item 1

item 2

item 3

`, + html: `

item 1

item 2

item 3

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

item 1

item 2

item 3

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte new file mode 100644 index 0000000000..ae54b63414 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-duplication/main.svelte @@ -0,0 +1,10 @@ + + +{#each messages as message} + +{/each}