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
pull/16801/head
Rich Harris 2 days ago committed by GitHub
parent 1c8456885c
commit 5c09035685
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: async hydration

@ -130,7 +130,7 @@ export function build_component(node, component_name, context) {
} else if (attribute.type === 'SpreadAttribute') { } else if (attribute.type === 'SpreadAttribute') {
const expression = /** @type {Expression} */ (context.visit(attribute)); 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( props_and_spreads.push(
b.thunk( b.thunk(
attribute.metadata.expression.has_await || attribute.metadata.expression.has_call attribute.metadata.expression.has_await || attribute.metadata.expression.has_call

@ -40,7 +40,11 @@ import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js'; import { UpdateExpression } from './visitors/UpdateExpression.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js'; import { VariableDeclaration } from './visitors/VariableDeclaration.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.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} */ /** @type {Visitors} */
const global_visitors = { const global_visitors = {
@ -244,7 +248,7 @@ export function server_component(analysis, options) {
]); ]);
if (analysis.instance.has_await) { 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 // trick esrap into including comments

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; 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 * @param {AST.AwaitBlock} node
@ -26,7 +26,7 @@ export function AwaitBlock(node, context) {
); );
if (node.metadata.expression.has_await) { 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); context.state.template.push(statement, block_close);

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; 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 * @param {AST.EachBlock} node
@ -64,7 +64,7 @@ export function EachBlock(node, context) {
} }
if (node.metadata.expression.has_await) { 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 { } else {
state.template.push(...block.body, block_close); state.template.push(...block.body, block_close);
} }

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; 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 * @param {AST.IfBlock} node
@ -24,7 +24,7 @@ export function IfBlock(node, context) {
let statement = b.if(test, consequent, alternate); let statement = b.if(test, consequent, alternate);
if (node.metadata.expression.has_await) { 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); context.state.template.push(statement, block_close);

@ -1,4 +1,4 @@
/** @import { Expression, Statement } from 'estree' */ /** @import { Expression } from 'estree' */
/** @import { Location } from 'locate-character' */ /** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
@ -12,7 +12,8 @@ import {
process_children, process_children,
build_template, build_template,
build_attribute_value, build_attribute_value,
call_child_renderer create_child_block,
PromiseOptimiser
} from './shared/utils.js'; } from './shared/utils.js';
/** /**
@ -27,21 +28,38 @@ export function RegularElement(node, context) {
...context.state, ...context.state,
namespace, namespace,
preserve_whitespace: 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); const node_is_void = is_void(node.name);
context.state.template.push(b.literal(`<${node.name}`)); const optimiser = new PromiseOptimiser();
const body = build_element_attributes(node, { ...context, state });
context.state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance 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) { 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(/** @type {AST.Text} */ (node.fragment.nodes[0]).data),
b.literal(`</${node.name}>`) b.literal(`</${node.name}>`)
); );
// 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; return;
} }
@ -77,29 +95,16 @@ export function RegularElement(node, context) {
); );
} }
let select_with_value = false; if (
let select_with_value_async = false; node.name === 'select' &&
const template_start = state.template.length; node.attributes.some(
if (node.name === 'select') {
const value = node.attributes.find(
(attribute) => (attribute) =>
(attribute.type === 'Attribute' || attribute.type === 'BindDirective') && ((attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value' attribute.name === 'value') ||
); attribute.type === 'SpreadAttribute'
)
const spread = node.attributes.find((attribute) => attribute.type === 'SpreadAttribute'); ) {
if (spread) { const attributes = build_spread_object(
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,
node.attributes.filter( node.attributes.filter(
(attribute) => (attribute) =>
@ -107,84 +112,75 @@ export function RegularElement(node, context) {
attribute.type === 'BindDirective' || attribute.type === 'BindDirective' ||
attribute.type === 'SpreadAttribute' attribute.type === 'SpreadAttribute'
), ),
context context,
), optimiser.transform
'value',
false,
true
)
)
)
); );
} else if (value) {
select_with_value = true;
if (value.type === 'Attribute' && value.value !== true) { const inner_state = { ...state, template: [], init: [] };
select_with_value_async ||= (Array.isArray(value.value) ? value.value : [value.value]).some( process_children(trimmed, { ...context, state: inner_state });
(tag) => tag.type === 'ExpressionTag' && tag.metadata.expression.has_await
);
}
const left = b.id('$$renderer.local.select_value'); const fn = b.arrow(
if (value.type === 'Attribute') { [b.id('$$renderer')],
state.template.push( b.block([...state.init, ...build_template(inner_state.template)])
b.stmt(b.assignment('=', left, build_attribute_value(value.value, context)))
); );
} else if (value.type === 'BindDirective') {
state.template.push( const statement = b.stmt(b.call('$$renderer.select', attributes, fn));
b.stmt(
b.assignment( if (optimiser.expressions.length > 0) {
'=', context.state.template.push(
left, create_child_block(b.block([optimiser.apply(), ...state.init, statement]), true)
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 ( if (node.name === 'option') {
node.name === 'option' && const attributes = build_spread_object(
!node.attributes.some( node,
node.attributes.filter(
(attribute) => (attribute) =>
attribute.type === 'SpreadAttribute' || attribute.type === 'Attribute' ||
((attribute.type === 'Attribute' || attribute.type === 'BindDirective') && attribute.type === 'BindDirective' ||
attribute.name === 'value') attribute.type === 'SpreadAttribute'
) ),
) { context,
optimiser.transform
);
let body;
if (node.metadata.synthetic_value_node) { if (node.metadata.synthetic_value_node) {
state.template.push( body = optimiser.transform(
b.stmt(
b.call(
'$.simple_valueless_option',
b.id('$$renderer'),
b.thunk(
node.metadata.synthetic_value_node.expression, node.metadata.synthetic_value_node.expression,
node.metadata.synthetic_value_node.metadata.expression.has_await node.metadata.synthetic_value_node.metadata.expression
)
)
)
); );
} else { } else {
const inner_state = { ...state, template: [], init: [] }; const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state }); process_children(trimmed, { ...context, state: inner_state });
state.template.push(
b.stmt( body = b.arrow(
b.call(
'$.valueless_option',
b.id('$$renderer'),
b.arrow(
[b.id('$$renderer')], [b.id('$$renderer')],
b.block([...inner_state.init, ...build_template(inner_state.template)]) b.block([...state.init, ...build_template(inner_state.template)])
) );
) }
)
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;
} }
} else if (body !== null) {
if (body !== null) {
// if this is a `<textarea>` value or a contenteditable binding, we only add // if this is a `<textarea>` value or a contenteditable binding, we only add
// the body if the attribute/binding is falsy // the body if the attribute/binding is falsy
const inner_state = { ...state, template: [], init: [] }; const inner_state = { ...state, template: [], init: [] };
@ -209,17 +205,6 @@ export function RegularElement(node, context) {
process_children(trimmed, { ...context, state }); process_children(trimmed, { ...context, state });
} }
if (select_with_value) {
// we need to create a child scope so that the `select_value` only applies children of this select element
// in an async world, we could technically have two adjacent select elements with async children, in which case
// the second element's select_value would override the first element's select_value if the children of the first
// element hadn't resolved prior to hitting the second element.
const elements = state.template.splice(template_start, Infinity);
state.template.push(
call_child_renderer(b.block(build_template(elements)), select_with_value_async)
);
}
if (!node_is_void) { if (!node_is_void) {
state.template.push(b.literal(`</${node.name}>`)); state.template.push(b.literal(`</${node.name}>`));
} }
@ -227,4 +212,16 @@ export function RegularElement(node, context) {
if (dev) { if (dev) {
state.template.push(b.stmt(b.call('$.pop_element'))); state.template.push(b.stmt(b.call('$.pop_element')));
} }
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);
}
} }

@ -3,10 +3,11 @@
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { import {
empty_comment,
build_attribute_value, build_attribute_value,
PromiseOptimiser, PromiseOptimiser,
call_child_renderer create_async_block,
block_open,
block_close
} from './shared/utils.js'; } from './shared/utils.js';
/** /**
@ -32,9 +33,9 @@ export function SlotElement(node, context) {
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
optimiser.transform,
false, false,
true, true
optimiser.transform
); );
if (attribute.name === 'name') { if (attribute.name === 'name') {
@ -66,8 +67,8 @@ export function SlotElement(node, context) {
const statement = const statement =
optimiser.expressions.length > 0 optimiser.expressions.length > 0
? call_child_renderer(b.block([optimiser.apply(), b.stmt(slot)]), true) ? create_async_block(b.block([optimiser.apply(), b.stmt(slot)]))
: b.stmt(slot); : b.stmt(slot);
context.state.template.push(empty_comment, statement, empty_comment); context.state.template.push(block_open, statement, block_close);
} }

@ -23,7 +23,13 @@ export function SvelteBoundary(node, context) {
if (pending_attribute || pending_snippet) { if (pending_attribute || pending_snippet) {
const pending = pending_attribute const pending = pending_attribute
? b.call( ? b.call(
build_attribute_value(pending_attribute.value, context, false, true), build_attribute_value(
pending_attribute.value,
context,
(expression) => expression,
false,
true
),
b.id('$$renderer') b.id('$$renderer')
) )
: /** @type {BlockStatement} */ (context.visit(pending_snippet.body)); : /** @type {BlockStatement} */ (context.visit(pending_snippet.body));

@ -1,12 +1,12 @@
/** @import { Location } from 'locate-character' */ /** @import { Location } from 'locate-character' */
/** @import { BlockStatement, Expression } from 'estree' */ /** @import { BlockStatement, Expression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import { dev, locator } from '../../../../state.js'; import { dev, locator } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { determine_namespace_for_children } from '../../utils.js'; import { determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes } from './shared/element.js'; import { build_element_attributes } from './shared/element.js';
import { build_template } from './shared/utils.js'; import { build_template, create_child_block, PromiseOptimiser } from './shared/utils.js';
/** /**
* @param {AST.SvelteElement} node * @param {AST.SvelteElement} node
@ -37,7 +37,9 @@ export function SvelteElement(node, context) {
init: [] init: []
}; };
build_element_attributes(node, { ...context, state }); const optimiser = new PromiseOptimiser();
build_element_attributes(node, { ...context, state }, optimiser.transform);
if (dev) { if (dev) {
const location = /** @type {Location} */ (locator(node.start)); const location = /** @type {Location} */ (locator(node.start));
@ -57,8 +59,8 @@ export function SvelteElement(node, context) {
const attributes = b.block([...state.init, ...build_template(state.template)]); const attributes = b.block([...state.init, ...build_template(state.template)]);
const children = /** @type {BlockStatement} */ (context.visit(node.fragment, state)); const children = /** @type {BlockStatement} */ (context.visit(node.fragment, state));
context.state.template.push( /** @type {Statement} */
b.stmt( let statement = b.stmt(
b.call( b.call(
'$.element', '$.element',
b.id('$$renderer'), b.id('$$renderer'),
@ -66,9 +68,14 @@ export function SvelteElement(node, context) {
attributes.body.length > 0 && b.thunk(attributes), attributes.body.length > 0 && b.thunk(attributes),
children.body.length > 0 && b.thunk(children) children.body.length > 0 && b.thunk(children)
) )
)
); );
if (optimiser.expressions.length > 0) {
statement = create_child_block(b.block([optimiser.apply(), statement]), true);
}
context.state.template.push(statement);
if (dev) { if (dev) {
context.state.template.push(b.stmt(b.call('$.pop_element'))); context.state.template.push(b.stmt(b.call('$.pop_element')));
} }

@ -14,6 +14,8 @@ export function TitleElement(node, context) {
template.push(b.literal('</title>')); template.push(b.literal('</title>'));
context.state.init.push( context.state.init.push(
b.stmt(b.call('$.build_title', b.id('$$renderer'), b.thunk(b.block(build_template(template))))) b.stmt(
b.call('$$renderer.title', b.arrow([b.id('$$renderer')], b.block(build_template(template))))
)
); );
} }

@ -4,8 +4,9 @@
import { import {
empty_comment, empty_comment,
build_attribute_value, build_attribute_value,
call_child_renderer, create_async_block,
PromiseOptimiser PromiseOptimiser,
build_template
} from './utils.js'; } from './utils.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { is_element_node } from '../../../../nodes.js'; import { is_element_node } from '../../../../nodes.js';
@ -91,9 +92,9 @@ export function build_inline_component(node, expression, context) {
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
optimiser.transform,
false, false,
true, true
optimiser.transform
); );
if (attribute.name.startsWith('--')) { if (attribute.name.startsWith('--')) {
@ -318,7 +319,7 @@ export function build_inline_component(node, expression, context) {
} }
if (optimiser.expressions.length > 0) { if (optimiser.expressions.length > 0) {
statement = call_child_renderer(b.block([optimiser.apply(), statement]), true); statement = create_async_block(b.block([optimiser.apply(), statement]));
} }
if (dynamic && custom_css_props.length === 0) { if (dynamic && custom_css_props.length === 0) {
@ -327,7 +328,11 @@ export function build_inline_component(node, expression, context) {
context.state.template.push(statement); context.state.template.push(statement);
if (!context.state.skip_hydration_boundaries && custom_css_props.length === 0) { if (
!context.state.skip_hydration_boundaries &&
custom_css_props.length === 0 &&
optimiser.expressions.length === 0
) {
context.state.template.push(empty_comment); context.state.template.push(empty_comment);
} }
} }

@ -1,5 +1,5 @@
/** @import { ArrayExpression, Expression, Literal, ObjectExpression } from 'estree' */ /** @import { ArrayExpression, Expression, Literal, ObjectExpression } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js'; import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import { binding_properties } from '../../../../bindings.js'; import { binding_properties } from '../../../../bindings.js';
@ -30,8 +30,9 @@ const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style'];
* their output to be the child content instead. In this case, an object is returned. * their output to be the child content instead. In this case, an object is returned.
* @param {AST.RegularElement | AST.SvelteElement} node * @param {AST.RegularElement | AST.SvelteElement} node
* @param {import('zimmerframe').Context<AST.SvelteNode, ComponentServerTransformState>} context * @param {import('zimmerframe').Context<AST.SvelteNode, ComponentServerTransformState>} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
export function build_element_attributes(node, context) { export function build_element_attributes(node, context, transform) {
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ /** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
const attributes = []; const attributes = [];
@ -62,7 +63,8 @@ export function build_element_attributes(node, context) {
// also see related code in analysis phase // also see related code in analysis phase
attribute.value[0].data = '\n' + attribute.value[0].data; attribute.value[0].data = '\n' + attribute.value[0].data;
} }
content = b.call('$.escape', build_attribute_value(attribute.value, context));
content = b.call('$.escape', build_attribute_value(attribute.value, context, transform));
} else if (node.name !== 'select') { } else if (node.name !== 'select') {
// omit value attribute for select elements, it's irrelevant for the initially selected value and has no // omit value attribute for select elements, it's irrelevant for the initially selected value and has no
// effect on the selected value after the user interacts with the select element (the value _property_ does, but not the attribute) // effect on the selected value after the user interacts with the select element (the value _property_ does, but not the attribute)
@ -150,12 +152,12 @@ export function build_element_attributes(node, context) {
expression: is_checkbox expression: is_checkbox
? b.call( ? b.call(
b.member(attribute.expression, 'includes'), b.member(attribute.expression, 'includes'),
build_attribute_value(value_attribute.value, context) build_attribute_value(value_attribute.value, context, transform)
) )
: b.binary( : b.binary(
'===', '===',
attribute.expression, attribute.expression,
build_attribute_value(value_attribute.value, context) build_attribute_value(value_attribute.value, context, transform)
), ),
metadata: { metadata: {
expression: create_expression_metadata() expression: create_expression_metadata()
@ -202,30 +204,14 @@ export function build_element_attributes(node, context) {
} }
if (has_spread) { if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context); build_element_spread_attributes(
if (node.name === 'option') {
context.state.template.push(
b.call(
'$.maybe_selected',
b.id('$$renderer'),
b.member(
build_spread_object(
node, node,
node.attributes.filter( attributes,
(attribute) => style_directives,
attribute.type === 'Attribute' || class_directives,
attribute.type === 'BindDirective' || context,
attribute.type === 'SpreadAttribute' transform
),
context
),
'value',
false,
true
)
)
); );
}
} else { } else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null; const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
@ -240,6 +226,7 @@ export function build_element_attributes(node, context) {
build_attribute_value( build_attribute_value(
attribute.value, attribute.value,
context, context,
transform,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name) WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
) )
).value; ).value;
@ -260,22 +247,13 @@ export function build_element_attributes(node, context) {
); );
} }
if (node.name === 'option' && name === 'value') {
context.state.template.push(
b.call(
'$.maybe_selected',
b.id('$$renderer'),
literal_value != null ? b.literal(/** @type {any} */ (literal_value)) : b.void0
)
);
}
continue; continue;
} }
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
transform,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name) WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
); );
@ -286,18 +264,16 @@ export function build_element_attributes(node, context) {
} }
context.state.template.push(b.literal(` ${name}="${escape_html(value.value, true)}"`)); context.state.template.push(b.literal(` ${name}="${escape_html(value.value, true)}"`));
} else if (name === 'class') { } else if (name === 'class') {
context.state.template.push(build_attr_class(class_directives, value, context, css_hash)); context.state.template.push(
build_attr_class(class_directives, value, context, css_hash, transform)
);
} else if (name === 'style') { } else if (name === 'style') {
context.state.template.push(build_attr_style(style_directives, value, context)); context.state.template.push(build_attr_style(style_directives, value, context, transform));
} else { } else {
context.state.template.push( context.state.template.push(
b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true) b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true)
); );
} }
if (name === 'value' && node.name === 'option') {
context.state.template.push(b.call('$.maybe_selected', b.id('$$renderer'), value));
}
} }
} }
@ -328,17 +304,20 @@ function get_attribute_name(element, attribute) {
* @param {AST.RegularElement | AST.SvelteElement} element * @param {AST.RegularElement | AST.SvelteElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} attributes
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
export function build_spread_object(element, attributes, context) { export function build_spread_object(element, attributes, context, transform) {
return b.object( const object = b.object(
attributes.map((attribute) => { attributes.map((attribute) => {
if (attribute.type === 'Attribute') { if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute); const name = get_attribute_name(element, attribute);
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
transform,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name) WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
); );
return b.prop('init', b.key(name), value); return b.prop('init', b.key(name), value);
} else if (attribute.type === 'BindDirective') { } else if (attribute.type === 'BindDirective') {
const name = get_attribute_name(element, attribute); const name = get_attribute_name(element, attribute);
@ -346,12 +325,20 @@ export function build_spread_object(element, attributes, context) {
attribute.expression.type === 'SequenceExpression' attribute.expression.type === 'SequenceExpression'
? b.call(attribute.expression.expressions[0]) ? b.call(attribute.expression.expressions[0])
: /** @type {Expression} */ (context.visit(attribute.expression)); : /** @type {Expression} */ (context.visit(attribute.expression));
return b.prop('init', b.key(name), value); return b.prop('init', b.key(name), value);
} }
return b.spread(/** @type {Expression} */ (context.visit(attribute))); return b.spread(
transform(
/** @type {Expression} */ (context.visit(attribute)),
attribute.metadata.expression
)
);
}) })
); );
return object;
} }
/** /**
@ -361,39 +348,48 @@ export function build_spread_object(element, attributes, context) {
* @param {AST.StyleDirective[]} style_directives * @param {AST.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives * @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
function build_element_spread_attributes( function build_element_spread_attributes(
element, element,
attributes, attributes,
style_directives, style_directives,
class_directives, class_directives,
context context,
transform
) { ) {
let classes; let classes;
let styles; let styles;
let flags = 0; let flags = 0;
let has_await = false;
if (class_directives.length) { if (class_directives.length) {
const properties = class_directives.map((directive) => const properties = class_directives.map((directive) => {
b.init( has_await ||= directive.metadata.expression.has_await;
return b.init(
directive.name, directive.name,
directive.expression.type === 'Identifier' && directive.expression.name === directive.name directive.expression.type === 'Identifier' && directive.expression.name === directive.name
? b.id(directive.name) ? b.id(directive.name)
: /** @type {Expression} */ (context.visit(directive.expression)) : /** @type {Expression} */ (context.visit(directive.expression))
)
); );
});
classes = b.object(properties); classes = b.object(properties);
} }
if (style_directives.length > 0) { if (style_directives.length > 0) {
const properties = style_directives.map((directive) => const properties = style_directives.map((directive) => {
b.init( has_await ||= directive.metadata.expression.has_await;
return b.init(
directive.name, directive.name,
directive.value === true directive.value === true
? b.id(directive.name) ? b.id(directive.name)
: build_attribute_value(directive.value, context, true) : build_attribute_value(directive.value, context, transform, true)
)
); );
});
styles = b.object(properties); styles = b.object(properties);
} }
@ -406,15 +402,18 @@ function build_element_spread_attributes(
flags |= ELEMENT_IS_INPUT; flags |= ELEMENT_IS_INPUT;
} }
const object = build_spread_object(element, attributes, context); const object = build_spread_object(element, attributes, context, transform);
const css_hash = const css_hash =
element.metadata.scoped && context.state.analysis.css.hash element.metadata.scoped && context.state.analysis.css.hash
? b.literal(context.state.analysis.css.hash) ? b.literal(context.state.analysis.css.hash)
: b.null; : undefined;
const args = [object, css_hash, classes, styles, flags ? b.literal(flags) : undefined]; const args = [object, css_hash, classes, styles, flags ? b.literal(flags) : undefined];
context.state.template.push(b.call('$.spread_attributes', ...args));
let call = b.call('$.attributes', ...args);
context.state.template.push(call);
} }
/** /**
@ -423,8 +422,9 @@ function build_element_spread_attributes(
* @param {Expression} expression * @param {Expression} expression
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {string | null} hash * @param {string | null} hash
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
function build_attr_class(class_directives, expression, context, hash) { function build_attr_class(class_directives, expression, context, hash, transform) {
/** @type {ObjectExpression | undefined} */ /** @type {ObjectExpression | undefined} */
let directives; let directives;
@ -434,7 +434,10 @@ function build_attr_class(class_directives, expression, context, hash) {
b.prop( b.prop(
'init', 'init',
b.literal(directive.name), b.literal(directive.name),
/** @type {Expression} */ (context.visit(directive.expression, context.state)) transform(
/** @type {Expression} */ (context.visit(directive.expression, context.state)),
directive.metadata.expression
)
) )
) )
); );
@ -457,9 +460,10 @@ function build_attr_class(class_directives, expression, context, hash) {
* *
* @param {AST.StyleDirective[]} style_directives * @param {AST.StyleDirective[]} style_directives
* @param {Expression} expression * @param {Expression} expression
* @param {ComponentContext} context * @param {ComponentContext} context,
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
function build_attr_style(style_directives, expression, context) { function build_attr_style(style_directives, expression, context, transform) {
/** @type {ArrayExpression | ObjectExpression | undefined} */ /** @type {ArrayExpression | ObjectExpression | undefined} */
let directives; let directives;
@ -471,7 +475,7 @@ function build_attr_style(style_directives, expression, context) {
const expression = const expression =
directive.value === true directive.value === true
? b.id(directive.name) ? b.id(directive.name)
: build_attribute_value(directive.value, context, true); : build_attribute_value(directive.value, context, transform, true);
let name = directive.name; let name = directive.name;
if (name[0] !== '-' || name[1] !== '-') { if (name[0] !== '-' || name[1] !== '-') {

@ -170,17 +170,17 @@ export function build_template(template) {
* *
* @param {AST.Attribute['value']} value * @param {AST.Attribute['value']} value
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
* @param {boolean} trim_whitespace * @param {boolean} trim_whitespace
* @param {boolean} is_component * @param {boolean} is_component
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
* @returns {Expression} * @returns {Expression}
*/ */
export function build_attribute_value( export function build_attribute_value(
value, value,
context, context,
transform,
trim_whitespace = false, trim_whitespace = false,
is_component = false, is_component = false
transform = (expression) => expression
) { ) {
if (value === true) { if (value === true) {
return b.true; return b.true;
@ -262,14 +262,23 @@ export function build_getter(node, state) {
} }
/** /**
* Creates a `$$renderer.child(...)` expression statement
* @param {BlockStatement | Expression} body * @param {BlockStatement | Expression} body
* @param {boolean} async * @param {boolean} async
* @returns {Statement} * @returns {Statement}
*/ */
export function call_child_renderer(body, async) { export function create_child_block(body, async) {
return b.stmt(b.call('$$renderer.child', b.arrow([b.id('$$renderer')], body, async))); return b.stmt(b.call('$$renderer.child', b.arrow([b.id('$$renderer')], body, async)));
} }
/**
* Creates a `$$renderer.async(...)` expression statement
* @param {BlockStatement | Expression} body
*/
export function create_async_block(body) {
return b.stmt(b.call('$$renderer.async', b.arrow([b.id('$$renderer')], body, true)));
}
/** /**
* @param {BlockStatement | Expression} body * @param {BlockStatement | Expression} body
* @param {Identifier | false} component_fn_id * @param {Identifier | false} component_fn_id

@ -1,6 +1,14 @@
/** @import { TemplateNode, Value } from '#client' */ /** @import { TemplateNode, Value } from '#client' */
import { flatten } from '../../reactivity/async.js'; import { flatten } from '../../reactivity/async.js';
import { get } from '../../runtime.js'; import { get } from '../../runtime.js';
import {
hydrate_next,
hydrate_node,
hydrating,
set_hydrate_node,
set_hydrating,
skip_nodes
} from '../hydration.js';
import { get_boundary } from './boundary.js'; import { get_boundary } from './boundary.js';
/** /**
@ -13,7 +21,22 @@ export function async(node, expressions, fn) {
boundary.update_pending_count(1); boundary.update_pending_count(1);
var was_hydrating = hydrating;
if (was_hydrating) {
hydrate_next();
var previous_hydrate_node = hydrate_node;
var end = skip_nodes(false);
set_hydrate_node(end);
}
flatten([], expressions, (values) => { flatten([], expressions, (values) => {
if (was_hydrating) {
set_hydrating(true);
set_hydrate_node(previous_hydrate_node);
}
try { try {
// get values eagerly to avoid creating blocks if they reject // get values eagerly to avoid creating blocks if they reject
for (const d of values) get(d); for (const d of values) get(d);
@ -22,5 +45,9 @@ export function async(node, expressions, fn) {
} finally { } finally {
boundary.update_pending_count(-1); boundary.update_pending_count(-1);
} }
if (was_hydrating) {
set_hydrating(false);
}
}); });
} }

@ -8,7 +8,7 @@ import {
hydrate_next, hydrate_next,
hydrate_node, hydrate_node,
hydrating, hydrating,
remove_nodes, skip_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
} from '../hydration.js'; } from '../hydration.js';
@ -140,7 +140,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
if (mismatch) { if (mismatch) {
// Hydration mismatch: remove everything inside the anchor and start fresh // Hydration mismatch: remove everything inside the anchor and start fresh
anchor = remove_nodes(); anchor = skip_nodes();
set_hydrate_node(anchor); set_hydrate_node(anchor);
set_hydrating(false); set_hydrating(false);

@ -21,7 +21,7 @@ import {
hydrate_node, hydrate_node,
hydrating, hydrating,
next, next,
remove_nodes, skip_nodes,
set_hydrate_node set_hydrate_node
} from '../hydration.js'; } from '../hydration.js';
import { get_next_sibling } from '../operations.js'; import { get_next_sibling } from '../operations.js';
@ -335,7 +335,7 @@ export class Boundary {
if (hydrating) { if (hydrating) {
set_hydrate_node(/** @type {TemplateNode} */ (this.#hydrate_open)); set_hydrate_node(/** @type {TemplateNode} */ (this.#hydrate_open));
next(); next();
set_hydrate_node(remove_nodes()); set_hydrate_node(skip_nodes());
} }
var did_reset = false; var did_reset = false;

@ -14,7 +14,7 @@ import {
hydrate_node, hydrate_node,
hydrating, hydrating,
read_hydration_instruction, read_hydration_instruction,
remove_nodes, skip_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
} from '../hydration.js'; } from '../hydration.js';
@ -209,7 +209,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (is_else !== (length === 0)) { if (is_else !== (length === 0)) {
// hydration mismatch — remove the server-rendered DOM and start over // hydration mismatch — remove the server-rendered DOM and start over
anchor = remove_nodes(); anchor = skip_nodes();
set_hydrate_node(anchor); set_hydrate_node(anchor);
set_hydrating(false); set_hydrating(false);
@ -259,7 +259,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
// remove excess nodes // remove excess nodes
if (length > 0) { if (length > 0) {
set_hydrate_node(remove_nodes()); set_hydrate_node(skip_nodes());
} }
} }

@ -6,7 +6,7 @@ import {
hydrate_node, hydrate_node,
hydrating, hydrating,
read_hydration_instruction, read_hydration_instruction,
remove_nodes, skip_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
} from '../hydration.js'; } from '../hydration.js';
@ -93,7 +93,7 @@ export function if_block(node, fn, elseif = false) {
if (!!condition === is_else) { if (!!condition === is_else) {
// Hydration mismatch: remove everything inside the anchor and start fresh. // Hydration mismatch: remove everything inside the anchor and start fresh.
// This could happen with `{#if browser}...{/if}`, for example // This could happen with `{#if browser}...{/if}`, for example
anchor = remove_nodes(); anchor = skip_nodes();
set_hydrate_node(anchor); set_hydrate_node(anchor);
set_hydrating(false); set_hydrating(false);

@ -81,9 +81,10 @@ export function next(count = 1) {
} }
/** /**
* Removes all nodes starting at `hydrate_node` up until the next hydration end comment * Skips or removes (depending on {@link remove}) all nodes starting at `hydrate_node` up until the next hydration end comment
* @param {boolean} remove
*/ */
export function remove_nodes() { export function skip_nodes(remove = true) {
var depth = 0; var depth = 0;
var node = hydrate_node; var node = hydrate_node;
@ -100,7 +101,7 @@ export function remove_nodes() {
} }
var next = /** @type {TemplateNode} */ (get_next_sibling(node)); var next = /** @type {TemplateNode} */ (get_next_sibling(node));
node.remove(); if (remove) node.remove();
node = next; node = next;
} }
} }

@ -108,13 +108,13 @@ export function css_props(renderer, is_html, props, component, dynamic = false)
/** /**
* @param {Record<string, unknown>} attrs * @param {Record<string, unknown>} attrs
* @param {string | null} css_hash * @param {string} [css_hash]
* @param {Record<string, boolean>} [classes] * @param {Record<string, boolean>} [classes]
* @param {Record<string, string>} [styles] * @param {Record<string, string>} [styles]
* @param {number} [flags] * @param {number} [flags]
* @returns {string} * @returns {string}
*/ */
export function spread_attributes(attrs, css_hash, classes, styles, flags = 0) { export function attributes(attrs, css_hash, classes, styles, flags = 0) {
if (styles) { if (styles) {
attrs.style = to_style(attrs.style, styles); attrs.style = to_style(attrs.style, styles);
} }
@ -493,121 +493,3 @@ export function derived(fn) {
return updated_value; return updated_value;
}; };
} }
/**
*
* @param {Renderer} renderer
* @param {unknown} value
*/
export function maybe_selected(renderer, value) {
return value === renderer.local.select_value ? ' selected' : '';
}
/**
* When an `option` element has no `value` attribute, we need to treat the child
* content as its `value` to determine whether we should apply the `selected` attribute.
* This has to be done at runtime, for hopefully obvious reasons. It is also complicated,
* for sad reasons.
* @param {Renderer} renderer
* @param {((renderer: Renderer) => void | Promise<void>)} children
* @returns {void}
*/
export function valueless_option(renderer, children) {
const i = renderer.length;
// prior to children, `renderer` has some combination of string/unresolved renderer that ends in `<option ...>`
renderer.child(children);
// post-children, `renderer` has child content, possibly also with some number of hydration comments.
// we can compact this last chunk of content to see if it matches the select value...
renderer.compact({
start: i,
fn: (content) => {
if (content.body.replace(/<!---->/g, '') === renderer.local.select_value) {
// ...and if it does match the select value, we can compact the part of the renderer representing the `<option ...>`
// to add the `selected` attribute to the end.
renderer.compact({
start: i - 1,
end: i,
fn: (content) => {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
});
}
return content;
}
});
}
/**
* In the special case where an `option` element has no `value` attribute but
* the children of the `option` element are a single expression, we can simplify
* by running the children and passing the resulting value, which means
* we don't have to do all of the same parsing nonsense. It also means we can avoid
* coercing everything to a string.
* @param {Renderer} renderer
* @param {(() => unknown)} child
*/
export function simple_valueless_option(renderer, child) {
const result = child();
/**
* @param {AccumulatedContent} content
* @param {unknown} child_value
* @returns {AccumulatedContent}
*/
const mark_selected = (content, child_value) => {
if (child_value === renderer.local.select_value) {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
return content;
};
renderer.compact({
start: renderer.length - 1,
fn: (content) => {
if (result instanceof Promise) {
return result.then((child_value) => mark_selected(content, child_value));
}
return mark_selected(content, result);
}
});
renderer.child((child_renderer) => {
if (result instanceof Promise) {
return result.then((child_value) => {
child_renderer.push(escape_html(child_value));
});
}
child_renderer.push(escape_html(result));
});
}
/**
* Since your document can only have one `title`, we have to have some sort of algorithm for determining
* which one "wins". To do this, we perform a depth-first comparison of where the title was encountered --
* later ones "win" over earlier ones, regardless of what order the promises resolve in. To accomodate this, we:
* - Figure out where we are in the content tree (`get_path`)
* - Render the title in its own child so that it has a defined "slot" in the renderer
* - Compact that spot so that we get the entire rendered contents of the title
* - Attempt to set the global title (this is where the "wins" logic based on the path happens)
*
* TODO we could optimize this by not even rendering the title if the path wouldn't be accepted
*
* @param {Renderer} renderer
* @param {((renderer: Renderer) => void | Promise<void>)} children
*/
export function build_title(renderer, children) {
const path = renderer.get_path();
const i = renderer.length;
renderer.child(children);
renderer.compact({
start: i,
fn: ({ head }) => {
renderer.global.set_title(head, path);
// since we can only ever render the title in this chunk, and title rendering is handled specially,
// we can just ditch the results after we've saved them globally
return { head: '', body: '' };
}
});
}

@ -6,6 +6,7 @@ import { pop, push, set_ssr_context, ssr_context } from './context.js';
import * as e from './errors.js'; import * as e from './errors.js';
import * as w from './warnings.js'; import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -55,12 +56,10 @@ export class Renderer {
#parent; #parent;
/** /**
* Asynchronous work associated with this renderer. `initial` is the promise from the function * Asynchronous work associated with this renderer
* this renderer was passed to (if that function was async), and `followup` is any any additional * @type {Promise<void> | undefined}
* work from `compact` calls that needs to complete prior to collecting this renderer's content.
* @type {{ initial: Promise<void> | undefined, followup: Promise<void>[] }}
*/ */
promises = { initial: undefined, followup: [] }; promise = undefined;
/** /**
* State which is associated with the content tree as a whole. * State which is associated with the content tree as a whole.
@ -101,6 +100,15 @@ export class Renderer {
head.child(fn); head.child(fn);
} }
/**
* @param {(renderer: Renderer) => void} fn
*/
async(fn) {
this.#out.push(BLOCK_OPEN);
this.child(fn);
this.#out.push(BLOCK_CLOSE);
}
/** /**
* Create a child renderer. The child renderer inherits the state from the parent, * Create a child renderer. The child renderer inherits the state from the parent,
* but has its own content. * but has its own content.
@ -129,7 +137,7 @@ export class Renderer {
} }
// just to avoid unhandled promise rejections -- we'll end up throwing in `collect_async` if something fails // just to avoid unhandled promise rejections -- we'll end up throwing in `collect_async` if something fails
result.catch(() => {}); result.catch(() => {});
child.promises.initial = result; child.promise = result;
} }
return child; return child;
@ -150,29 +158,100 @@ export class Renderer {
} }
/** /**
* @param {string | (() => Promise<string>)} content * @param {Record<string, any>} attrs
* @param {(renderer: Renderer) => void} fn
*/ */
push(content) { select({ value, ...attrs }, fn) {
if (typeof content === 'function') { this.push(`<select${attributes(attrs)}>`);
this.child(async (renderer) => renderer.push(await content())); this.child((renderer) => {
renderer.local.select_value = value;
fn(renderer);
});
this.push('</select>');
}
/**
* @param {Record<string, any>} attrs
* @param {string | number | boolean | ((renderer: Renderer) => void)} body
*/
option(attrs, body) {
this.#out.push(`<option${attributes(attrs)}`);
/**
* @param {Renderer} renderer
* @param {any} value
* @param {{ head?: string, body: any }} content
*/
const close = (renderer, value, { head, body }) => {
if ('value' in attrs) {
value = attrs.value;
}
if (value === this.local.select_value) {
renderer.#out.push(' selected');
}
renderer.#out.push(`>${body}</option>`);
// super edge case, but may as well handle it
if (head) {
renderer.head((child) => child.push(head));
}
};
if (typeof body === 'function') {
this.child((renderer) => {
const r = new Renderer(this.global, this);
body(r);
if (this.global.mode === 'async') {
return r.#collect_content_async().then((content) => {
close(renderer, content.body.replaceAll('<!---->', ''), content);
});
} else { } else {
this.#out.push(content); const content = r.#collect_content();
close(renderer, content.body.replaceAll('<!---->', ''), content);
}
});
} else {
close(this, body, { body });
} }
} }
/** /**
* Compact everything between `start` and `end` into a single renderer, then call `fn` with the result of that renderer. * @param {(renderer: Renderer) => void} fn
* The compacted renderer will be sync if all of the children are sync and {@link fn} is sync, otherwise it will be async.
* @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> }} args
*/ */
compact({ start, end = this.#out.length, fn }) { title(fn) {
const child = new Renderer(this.global, this); const path = this.get_path();
const to_compact = this.#out.splice(start, end - start, child);
/** @param {string} head */
const close = (head) => {
this.global.set_title(head, path);
};
this.child((renderer) => {
const r = new Renderer(renderer.global, renderer);
fn(r);
if (this.global.mode === 'sync') { if (renderer.global.mode === 'async') {
Renderer.#compact(fn, child, to_compact, this.type); return r.#collect_content_async().then((content) => {
close(content.head);
});
} else { } else {
this.promises.followup.push(Renderer.#compact_async(fn, child, to_compact, this.type)); const content = r.#collect_content();
close(content.head);
}
});
}
/**
* @param {string | (() => Promise<string>)} content
*/
push(content) {
if (typeof content === 'function') {
this.child(async (renderer) => renderer.push(await content()));
} else {
this.#out.push(content);
} }
} }
@ -196,7 +275,7 @@ export class Renderer {
copy() { copy() {
const copy = new Renderer(this.global, this.#parent); const copy = new Renderer(this.global, this.#parent);
copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item)); copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item));
copy.promises = this.promises; copy.promise = this.promise;
return copy; return copy;
} }
@ -218,7 +297,7 @@ export class Renderer {
} }
return item; return item;
}); });
this.promises = other.promises; this.promise = other.promise;
this.type = other.type; this.type = other.type;
} }
@ -355,7 +434,7 @@ export class Renderer {
try { try {
const renderer = Renderer.#open_render('sync', component, options); const renderer = Renderer.#open_render('sync', component, options);
const content = Renderer.#collect_content([renderer], renderer.type); const content = renderer.#collect_content();
return Renderer.#close_render(content, renderer); return Renderer.#close_render(content, renderer);
} finally { } finally {
abort(); abort();
@ -376,7 +455,7 @@ export class Renderer {
try { try {
const renderer = Renderer.#open_render('async', component, options); const renderer = Renderer.#open_render('async', component, options);
const content = await Renderer.#collect_content_async([renderer], renderer.type); const content = await renderer.#collect_content_async();
return Renderer.#close_render(content, renderer); return Renderer.#close_render(content, renderer);
} finally { } finally {
abort(); abort();
@ -384,94 +463,41 @@ export class Renderer {
} }
} }
/**
* @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn
* @param {Renderer} child
* @param {RendererItem[]} to_compact
* @param {RendererType} type
*/
static #compact(fn, child, to_compact, type) {
const content = Renderer.#collect_content(to_compact, type);
const transformed_content = fn(content);
if (transformed_content instanceof Promise) {
throw new Error(
"invariant: Somehow you've encountered asynchronous work while rendering synchronously. If you're seeing this, there's a compiler bug. File an issue!"
);
} else {
Renderer.#push_accumulated_content(child, transformed_content);
}
}
/**
* @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn
* @param {Renderer} child
* @param {RendererItem[]} to_compact
* @param {RendererType} type
*/
static async #compact_async(fn, child, to_compact, type) {
const content = await Renderer.#collect_content_async(to_compact, type);
const transformed_content = await fn(content);
Renderer.#push_accumulated_content(child, transformed_content);
}
/** /**
* Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string. * Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string.
* @param {RendererItem[]} items
* @param {RendererType} current_type
* @param {AccumulatedContent} content * @param {AccumulatedContent} content
* @returns {AccumulatedContent} * @returns {AccumulatedContent}
*/ */
static #collect_content(items, current_type, content = { head: '', body: '' }) { #collect_content(content = { head: '', body: '' }) {
for (const item of items) { for (const item of this.#out) {
if (typeof item === 'string') { if (typeof item === 'string') {
content[current_type] += item; content[this.type] += item;
} else if (item instanceof Renderer) { } else if (item instanceof Renderer) {
Renderer.#collect_content(item.#out, item.type, content); item.#collect_content(content);
} }
} }
return content; return content;
} }
/** /**
* Collect all of the code from the `out` array and return it as a string. * Collect all of the code from the `out` array and return it as a string.
* @param {RendererItem[]} items
* @param {RendererType} current_type
* @param {AccumulatedContent} content * @param {AccumulatedContent} content
* @returns {Promise<AccumulatedContent>} * @returns {Promise<AccumulatedContent>}
*/ */
static async #collect_content_async(items, current_type, content = { head: '', body: '' }) { async #collect_content_async(content = { head: '', body: '' }) {
await this.promise;
// no danger to sequentially awaiting stuff in here; all of the work is already kicked off // no danger to sequentially awaiting stuff in here; all of the work is already kicked off
for (const item of items) { for (const item of this.#out) {
if (typeof item === 'string') { if (typeof item === 'string') {
content[current_type] += item; content[this.type] += item;
} else { } else if (item instanceof Renderer) {
if (item.promises.initial) { await item.#collect_content_async(content);
// this represents the async function that's modifying this renderer.
// we can't do anything until it's done and we know our `out` array is complete.
await item.promises.initial;
}
for (const followup of item.promises.followup) {
// this is sequential because `compact` could synchronously queue up additional followup work
await followup;
}
await Renderer.#collect_content_async(item.#out, item.type, content);
}
} }
return content;
} }
/** return content;
* @param {Renderer} tree
* @param {AccumulatedContent} accumulated_content
*/
static #push_accumulated_content(tree, accumulated_content) {
for (const [type, content] of Object.entries(accumulated_content)) {
if (!content) continue;
const child = new Renderer(tree.global, tree);
child.type = /** @type {RendererType} */ (type);
child.push(content);
tree.#out.push(child);
}
} }
/** /**

@ -84,26 +84,6 @@ test('creating an async child in a sync context throws', () => {
expect(() => Renderer.render(component as unknown as Component).body).toThrow('await_invalid'); expect(() => Renderer.render(component as unknown as Component).body).toThrow('await_invalid');
}); });
test('compact synchronously aggregates a range and can transform into head/body', () => {
const component = (renderer: Renderer) => {
const start = renderer.length;
renderer.push('a');
renderer.push('b');
renderer.push('c');
renderer.compact({
start,
end: start + 2,
fn: (content) => {
return { head: '<h>H</h>', body: content.body + 'd' };
}
});
};
const { head, body } = Renderer.render(component as unknown as Component);
expect(head).toBe('<h>H</h>');
expect(body).toBe('<!--[-->abdc<!--]-->');
});
test('local state is shallow-copied to children', () => { test('local state is shallow-copied to children', () => {
const root = new Renderer(new SSRState('sync')); const root = new Renderer(new SSRState('sync'));
root.local.select_value = 'A'; root.local.select_value = 'A';
@ -132,13 +112,13 @@ test('subsume replaces tree content and state from other', () => {
b.global.css.add({ hash: 'h', code: 'c' }); b.global.css.add({ hash: 'h', code: 'c' });
b.global.set_title('Title', [1]); b.global.set_title('Title', [1]);
b.local.select_value = 'B'; b.local.select_value = 'B';
b.promises.initial = Promise.resolve(); b.promise = Promise.resolve();
a.subsume(b); a.subsume(b);
expect(a.type).toBe('body'); expect(a.type).toBe('body');
expect(a.local.select_value).toBe('B'); expect(a.local.select_value).toBe('B');
expect(a.promises).toBe(b.promises); expect(a.promise).toBe(b.promise);
}); });
test('subsume refuses to switch modes', () => { test('subsume refuses to switch modes', () => {
@ -156,7 +136,7 @@ test('subsume refuses to switch modes', () => {
b.global.css.add({ hash: 'h', code: 'c' }); b.global.css.add({ hash: 'h', code: 'c' });
b.global.set_title('Title', [1]); b.global.set_title('Title', [1]);
b.local.select_value = 'B'; b.local.select_value = 'B';
b.promises.initial = Promise.resolve(); b.promise = Promise.resolve();
expect(() => a.subsume(b)).toThrow( expect(() => a.subsume(b)).toThrow(
"invariant: A renderer cannot switch modes. If you're seeing this, there's a compiler bug. File an issue!" "invariant: A renderer cannot switch modes. If you're seeing this, there's a compiler bug. File an issue!"
@ -195,6 +175,38 @@ test('SSRState title ordering favors later lexicographic paths', () => {
expect(state.get_title()).toBe('E'); expect(state.get_title()).toBe('E');
}); });
test('selects an option with an explicit value', () => {
const component = (renderer: Renderer) => {
renderer.select({ value: 2 }, (renderer) => {
renderer.option({ value: 1 }, (renderer) => renderer.push('one'));
renderer.option({ value: 2 }, (renderer) => renderer.push('two'));
renderer.option({ value: 3 }, (renderer) => renderer.push('three'));
});
};
const { head, body } = Renderer.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe(
'<!--[--><select><option value="1">one</option><option value="2" selected>two</option><option value="3">three</option></select><!--]-->'
);
});
test('selects an option with an implicit value', () => {
const component = (renderer: Renderer) => {
renderer.select({ value: 'two' }, (renderer) => {
renderer.option({}, (renderer) => renderer.push('one'));
renderer.option({}, (renderer) => renderer.push('two'));
renderer.option({}, (renderer) => renderer.push('three'));
});
};
const { head, body } = Renderer.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe(
'<!--[--><select><option>one</option><option selected>two</option><option>three</option></select><!--]-->'
);
});
describe('async', () => { describe('async', () => {
beforeAll(() => { beforeAll(() => {
enable_async_mode_flag(); enable_async_mode_flag();
@ -220,28 +232,6 @@ describe('async', () => {
expect(() => result.html).toThrow('html_deprecated'); expect(() => result.html).toThrow('html_deprecated');
}); });
test('compact schedules followup when compaction input is async', async () => {
const component = (renderer: Renderer) => {
renderer.push('a');
renderer.child(async ($$renderer) => {
await Promise.resolve();
$$renderer.push('X');
});
renderer.push('b');
renderer.compact({
start: 0,
fn: async (content) => ({
body: content.body.toLowerCase(),
head: await Promise.resolve('')
})
});
};
const { body, head } = await Renderer.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[-->axb<!--]-->');
});
test('push accepts async functions in async context', async () => { test('push accepts async functions in async context', async () => {
const component = (renderer: Renderer) => { const component = (renderer: Renderer) => {
renderer.push('a'); renderer.push('a');
@ -308,25 +298,6 @@ describe('async', () => {
expect(body).toBe('<!--[-->start-async-child--end<!--]-->'); expect(body).toBe('<!--[-->start-async-child--end<!--]-->');
}); });
test('push async functions work with compact operations', async () => {
const component = (renderer: Renderer) => {
renderer.push('a');
renderer.push(async () => {
await Promise.resolve();
return 'b';
});
renderer.push('c');
renderer.compact({
start: 0,
fn: (content) => ({ head: '', body: content.body.toUpperCase() })
});
};
const { head, body } = await Renderer.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[-->ABC<!--]-->');
});
test('push async functions are not supported in sync context', () => { test('push async functions are not supported in sync context', () => {
const component = (renderer: Renderer) => { const component = (renderer: Renderer) => {
renderer.push('a'); renderer.push('a');

@ -1 +1 @@
<!--[--><!--[--><!--]--> <div><!----><!----></div> hello<!--]--> <!--[--><!--[--><!--]--> <div><!--[--><!--]--></div> hello<!--]-->

@ -0,0 +1,5 @@
<script>
let { thing } = $props();
</script>
<p>{thing}</p>

@ -0,0 +1,24 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip_mode: ['async-server'],
async test({ assert, target }) {
await tick();
assert.htmlEqual(
target.innerHTML,
`
<p class="cool">cool</p>
<p>beans</p>
<p class="awesome">awesome</p>
<p>sauce</p>
<p class="neato">neato</p>
<p>burrito</p>
`
);
}
});

@ -0,0 +1,12 @@
<script>
import Child from './Child.svelte';
</script>
<p {...await { class: 'cool'}}>cool</p>
<Child {...await { thing: 'beans' }} />
<p class={await 'awesome'}>awesome</p>
<Child thing={await 'sauce'} />
<p {...{}} class={await 'neato'}>neato</p>
<Child {...{}} thing={await 'burrito'} />

@ -0,0 +1,33 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate'],
server_props: {
browser: false
},
ssrHtml: `
<h1>hello from the server</h1>
<h2>hello from the server</h2>
<h3>hello from the server</h3>
`,
props: {
browser: true
},
async test({ assert, target }) {
await tick();
assert.htmlEqual(
target.innerHTML,
`
<h1>hello from the browser</h1>
<h2>hello from the browser</h2>
<h3>hello from the browser</h3>
`
);
}
});

@ -0,0 +1,10 @@
<script>
let { browser = typeof window !== 'undefined' } = $props();
</script>
{#if await true}
<h1>hello from the {browser ? 'browser' : 'server'}</h1>
{/if}
<h2>hello from the {browser ? 'browser' : 'server'}</h2>
<h3>hello from the {browser ? 'browser' : 'server'}</h3>

@ -2,7 +2,7 @@ import 'svelte/internal/flags/async';
import * as $ from 'svelte/internal/server'; import * as $ from 'svelte/internal/server';
export default function Async_each_fallback_hoisting($$renderer) { export default function Async_each_fallback_hoisting($$renderer) {
$$renderer.child(async ($$renderer) => { $$renderer.async(async ($$renderer) => {
const each_array = $.ensure_array_like((await $.save(Promise.resolve([])))()); const each_array = $.ensure_array_like((await $.save(Promise.resolve([])))());
if (each_array.length !== 0) { if (each_array.length !== 0) {

@ -8,7 +8,7 @@ export default function Async_each_hoisting($$renderer) {
$$renderer.push(`<!--[-->`); $$renderer.push(`<!--[-->`);
$$renderer.child(async ($$renderer) => { $$renderer.async(async ($$renderer) => {
const each_array = $.ensure_array_like((await $.save(Promise.resolve([first, second, third])))()); const each_array = $.ensure_array_like((await $.save(Promise.resolve([first, second, third])))());
for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) { for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {

@ -2,7 +2,7 @@ import 'svelte/internal/flags/async';
import * as $ from 'svelte/internal/server'; import * as $ from 'svelte/internal/server';
export default function Async_if_alternate_hoisting($$renderer) { export default function Async_if_alternate_hoisting($$renderer) {
$$renderer.child(async ($$renderer) => { $$renderer.async(async ($$renderer) => {
if ((await $.save(Promise.resolve(false)))()) { if ((await $.save(Promise.resolve(false)))()) {
$$renderer.push('<!--[-->'); $$renderer.push('<!--[-->');
$$renderer.push(async () => $.escape(await Promise.reject('no no no'))); $$renderer.push(async () => $.escape(await Promise.reject('no no no')));

@ -2,7 +2,7 @@ import 'svelte/internal/flags/async';
import * as $ from 'svelte/internal/server'; import * as $ from 'svelte/internal/server';
export default function Async_if_hoisting($$renderer) { export default function Async_if_hoisting($$renderer) {
$$renderer.child(async ($$renderer) => { $$renderer.async(async ($$renderer) => {
if ((await $.save(Promise.resolve(true)))()) { if ((await $.save(Promise.resolve(true)))()) {
$$renderer.push('<!--[-->'); $$renderer.push('<!--[-->');
$$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes'))); $$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes')));

@ -3,5 +3,11 @@ import * as $ from 'svelte/internal/server';
export default function Skip_static_subtree($$renderer, $$props) { export default function Skip_static_subtree($$renderer, $$props) {
let { title, content } = $$props; let { title, content } = $$props;
$$renderer.push(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a"${$.maybe_selected($$renderer, 'a')}>a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`); $$renderer.push(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select>`);
$$renderer.option({ value: 'a' }, ($$renderer) => {
$$renderer.push(`a`);
});
$$renderer.push(`</select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`);
} }
Loading…
Cancel
Save