From 5c09035685d0edd4c21453cd4b2f10fe984f913a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 19 Sep 2025 19:36:31 -0400 Subject: [PATCH] fix: async hydration (#16797) * WIP fix async hydration * add renderer.async method * update tests * changeset * oops * WIP fix async attributes * fix * fix * all tests passing * unused * unused * remove_nodes -> skip_nodes * hydration boundaries around slots * reorder arguments * add select method * WIP simplify selects * WIP * simplify * renderer.title * delete unused compact method * simplify * simplify * simplify * simplify * fix TODO * remove outdated TODO * remove outdated TODO * rename call_child_renderer -> create_child_block * burrito * add a couple of unit tests --- .changeset/six-rabbits-pull.md | 5 + .../client/visitors/shared/component.js | 2 +- .../3-transform/server/transform-server.js | 8 +- .../3-transform/server/visitors/AwaitBlock.js | 4 +- .../3-transform/server/visitors/EachBlock.js | 4 +- .../3-transform/server/visitors/IfBlock.js | 4 +- .../server/visitors/RegularElement.js | 221 +++++++++--------- .../server/visitors/SlotElement.js | 13 +- .../server/visitors/SvelteBoundary.js | 8 +- .../server/visitors/SvelteElement.js | 31 ++- .../server/visitors/TitleElement.js | 4 +- .../server/visitors/shared/component.js | 17 +- .../server/visitors/shared/element.js | 136 +++++------ .../server/visitors/shared/utils.js | 17 +- .../src/internal/client/dom/blocks/async.js | 27 +++ .../src/internal/client/dom/blocks/await.js | 4 +- .../internal/client/dom/blocks/boundary.js | 4 +- .../src/internal/client/dom/blocks/each.js | 6 +- .../src/internal/client/dom/blocks/if.js | 4 +- .../src/internal/client/dom/hydration.js | 7 +- packages/svelte/src/internal/server/index.js | 122 +--------- .../svelte/src/internal/server/renderer.js | 206 +++++++++------- .../src/internal/server/renderer.test.ts | 99 +++----- .../if-block-mismatch-2/_expected.html | 2 +- .../async-no-pending-attributes/Child.svelte | 5 + .../async-no-pending-attributes/_config.js | 24 ++ .../async-no-pending-attributes/main.svelte | 12 + .../_config.js | 33 +++ .../main.svelte | 10 + .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 8 +- 34 files changed, 546 insertions(+), 509 deletions(-) create mode 100644 .changeset/six-rabbits-pull.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-no-pending-attributes/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-no-pending-attributes/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-no-pending-attributes/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-no-pending-await-in-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-no-pending-await-in-block/main.svelte diff --git a/.changeset/six-rabbits-pull.md b/.changeset/six-rabbits-pull.md new file mode 100644 index 0000000000..55fc8c89b8 --- /dev/null +++ b/.changeset/six-rabbits-pull.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: async hydration 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 7feeebdbbc..5c8ce897f4 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 @@ -130,7 +130,7 @@ export function build_component(node, component_name, context) { } else if (attribute.type === 'SpreadAttribute') { const expression = /** @type {Expression} */ (context.visit(attribute)); - if (attribute.metadata.expression.has_state) { + if (attribute.metadata.expression.has_state || attribute.metadata.expression.has_await) { props_and_spreads.push( b.thunk( attribute.metadata.expression.has_await || attribute.metadata.expression.has_call diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 994db533c2..90845fb303 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -40,7 +40,11 @@ import { TitleElement } from './visitors/TitleElement.js'; import { UpdateExpression } from './visitors/UpdateExpression.js'; import { VariableDeclaration } from './visitors/VariableDeclaration.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js'; -import { call_child_renderer, call_component_renderer } from './visitors/shared/utils.js'; +import { + create_child_block, + call_component_renderer, + create_async_block +} from './visitors/shared/utils.js'; /** @type {Visitors} */ const global_visitors = { @@ -244,7 +248,7 @@ export function server_component(analysis, options) { ]); if (analysis.instance.has_await) { - component_block = b.block([call_child_renderer(component_block, true)]); + component_block = b.block([create_child_block(component_block, true)]); } // trick esrap into including comments diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js index e88f117c5c..132a83ec60 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, call_child_renderer } from './shared/utils.js'; +import { block_close, create_async_block } from './shared/utils.js'; /** * @param {AST.AwaitBlock} node @@ -26,7 +26,7 @@ export function AwaitBlock(node, context) { ); if (node.metadata.expression.has_await) { - statement = call_child_renderer(b.block([statement]), true); + statement = create_async_block(b.block([statement])); } context.state.template.push(statement, block_close); 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 a8821c4ee7..5a7ca8b566 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 @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, block_open, block_open_else, call_child_renderer } from './shared/utils.js'; +import { block_close, block_open, block_open_else, create_async_block } from './shared/utils.js'; /** * @param {AST.EachBlock} node @@ -64,7 +64,7 @@ export function EachBlock(node, context) { } if (node.metadata.expression.has_await) { - state.template.push(call_child_renderer(block, true), block_close); + state.template.push(create_async_block(block), block_close); } else { state.template.push(...block.body, block_close); } 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 426e468927..798cb02e49 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 @@ -2,7 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; -import { block_close, block_open, block_open_else, call_child_renderer } from './shared/utils.js'; +import { block_close, block_open, block_open_else, create_async_block } from './shared/utils.js'; /** * @param {AST.IfBlock} node @@ -24,7 +24,7 @@ export function IfBlock(node, context) { let statement = b.if(test, consequent, alternate); if (node.metadata.expression.has_await) { - statement = call_child_renderer(b.block([statement]), true); + statement = create_async_block(b.block([statement])); } context.state.template.push(statement, block_close); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index 60ec158b53..14f48d1ce7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -1,4 +1,4 @@ -/** @import { Expression, Statement } from 'estree' */ +/** @import { Expression } from 'estree' */ /** @import { Location } from 'locate-character' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */ @@ -12,7 +12,8 @@ import { process_children, build_template, build_attribute_value, - call_child_renderer + create_child_block, + PromiseOptimiser } from './shared/utils.js'; /** @@ -27,21 +28,38 @@ export function RegularElement(node, context) { ...context.state, namespace, preserve_whitespace: - context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea' + context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea', + init: [], + template: [] }; const node_is_void = is_void(node.name); - context.state.template.push(b.literal(`<${node.name}`)); - const body = build_element_attributes(node, { ...context, state }); - context.state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance + const optimiser = new PromiseOptimiser(); + + state.template.push(b.literal(`<${node.name}`)); + const body = build_element_attributes(node, { ...context, state }, optimiser.transform); + state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance if ((node.name === 'script' || node.name === 'style') && node.fragment.nodes.length === 1) { - context.state.template.push( + state.template.push( b.literal(/** @type {AST.Text} */ (node.fragment.nodes[0]).data), b.literal(``) ); + // TODO this is a real edge case, would be good to DRY this out + if (optimiser.expressions.length > 0) { + context.state.template.push( + create_child_block( + b.block([optimiser.apply(), ...state.init, ...build_template(state.template)]), + true + ) + ); + } else { + context.state.init.push(...state.init); + context.state.template.push(...state.template); + } + return; } @@ -77,114 +95,92 @@ export function RegularElement(node, context) { ); } - let select_with_value = false; - let select_with_value_async = false; - const template_start = state.template.length; - - if (node.name === 'select') { - const value = node.attributes.find( + if ( + node.name === 'select' && + node.attributes.some( (attribute) => - (attribute.type === 'Attribute' || attribute.type === 'BindDirective') && - attribute.name === 'value' + ((attribute.type === 'Attribute' || attribute.type === 'BindDirective') && + attribute.name === 'value') || + attribute.type === 'SpreadAttribute' + ) + ) { + const attributes = build_spread_object( + node, + node.attributes.filter( + (attribute) => + attribute.type === 'Attribute' || + attribute.type === 'BindDirective' || + attribute.type === 'SpreadAttribute' + ), + context, + optimiser.transform ); - const spread = node.attributes.find((attribute) => attribute.type === 'SpreadAttribute'); - if (spread) { - select_with_value = true; - select_with_value_async ||= spread.metadata.expression.has_await; - - state.template.push( - b.stmt( - b.assignment( - '=', - b.id('$$renderer.local.select_value'), - b.member( - build_spread_object( - node, - node.attributes.filter( - (attribute) => - attribute.type === 'Attribute' || - attribute.type === 'BindDirective' || - attribute.type === 'SpreadAttribute' - ), - context - ), - 'value', - false, - true - ) - ) - ) + const inner_state = { ...state, template: [], init: [] }; + process_children(trimmed, { ...context, state: inner_state }); + + const fn = b.arrow( + [b.id('$$renderer')], + b.block([...state.init, ...build_template(inner_state.template)]) + ); + + const statement = b.stmt(b.call('$$renderer.select', attributes, fn)); + + if (optimiser.expressions.length > 0) { + context.state.template.push( + create_child_block(b.block([optimiser.apply(), ...state.init, statement]), true) ); - } else if (value) { - select_with_value = true; - - if (value.type === 'Attribute' && value.value !== true) { - select_with_value_async ||= (Array.isArray(value.value) ? value.value : [value.value]).some( - (tag) => tag.type === 'ExpressionTag' && tag.metadata.expression.has_await - ); - } - - const left = b.id('$$renderer.local.select_value'); - if (value.type === 'Attribute') { - state.template.push( - b.stmt(b.assignment('=', left, build_attribute_value(value.value, context))) - ); - } else if (value.type === 'BindDirective') { - state.template.push( - b.stmt( - b.assignment( - '=', - left, - value.expression.type === 'SequenceExpression' - ? /** @type {Expression} */ (context.visit(b.call(value.expression.expressions[0]))) - : /** @type {Expression} */ (context.visit(value.expression)) - ) - ) - ); - } + } else { + context.state.template.push(...state.init, statement); } + + return; } - if ( - node.name === 'option' && - !node.attributes.some( - (attribute) => - attribute.type === 'SpreadAttribute' || - ((attribute.type === 'Attribute' || attribute.type === 'BindDirective') && - attribute.name === 'value') - ) - ) { + if (node.name === 'option') { + const attributes = build_spread_object( + node, + node.attributes.filter( + (attribute) => + attribute.type === 'Attribute' || + attribute.type === 'BindDirective' || + attribute.type === 'SpreadAttribute' + ), + context, + optimiser.transform + ); + + let body; + if (node.metadata.synthetic_value_node) { - state.template.push( - b.stmt( - b.call( - '$.simple_valueless_option', - b.id('$$renderer'), - b.thunk( - node.metadata.synthetic_value_node.expression, - node.metadata.synthetic_value_node.metadata.expression.has_await - ) - ) - ) + body = optimiser.transform( + node.metadata.synthetic_value_node.expression, + node.metadata.synthetic_value_node.metadata.expression ); } else { const inner_state = { ...state, template: [], init: [] }; process_children(trimmed, { ...context, state: inner_state }); - state.template.push( - b.stmt( - b.call( - '$.valueless_option', - b.id('$$renderer'), - b.arrow( - [b.id('$$renderer')], - b.block([...inner_state.init, ...build_template(inner_state.template)]) - ) - ) - ) + + body = b.arrow( + [b.id('$$renderer')], + b.block([...state.init, ...build_template(inner_state.template)]) ); } - } else if (body !== null) { + + const statement = b.stmt(b.call('$$renderer.option', attributes, body)); + + if (optimiser.expressions.length > 0) { + context.state.template.push( + create_child_block(b.block([optimiser.apply(), ...state.init, statement]), true) + ); + } else { + context.state.template.push(...state.init, statement); + } + + return; + } + + if (body !== null) { // if this is a `