fix: parallelize async `@const`s in the template (#17165)

* fix: parallelize async `@const`s in the template

This fixes #17075 by solving the TODO of #17038 to add out of order rendering for async `@const` declarations in the template.

It's implemented by a new field on the component state which is set as soon as we come across an async const. All async const declarations and those after it will be added to that field, and the existing blockers mechanism is then used to line up the async work correctly. After processing a fragment a `run` command is created from the collected consts.

* fix

* tweak

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/17169/head
Simon H 3 days ago committed by GitHub
parent b9c7e45408
commit 7fd2d8660f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure async `@const` in boundary hydrates correctly

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: parallelize async `@const`s in the template

@ -10,8 +10,7 @@ export function create_fragment(transparent = false) {
nodes: [], nodes: [],
metadata: { metadata: {
transparent, transparent,
dynamic: false, dynamic: false
has_await: false
} }
}; };
} }

@ -26,10 +26,6 @@ export function AwaitExpression(node, context) {
if (context.state.expression) { if (context.state.expression) {
context.state.expression.has_await = true; context.state.expression.has_await = true;
if (context.state.fragment && context.path.some((node) => node.type === 'ConstTag')) {
context.state.fragment.metadata.has_await = true;
}
suspend = true; suspend = true;
} }

@ -51,6 +51,11 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly after_update: Statement[]; readonly after_update: Statement[];
/** Transformed `{@const }` declarations */ /** Transformed `{@const }` declarations */
readonly consts: Statement[]; readonly consts: Statement[];
/** Transformed async `{@const }` declarations (if any) and those coming after them */
async_consts?: {
id: Identifier;
thunks: Expression[];
};
/** Transformed `let:` directives */ /** Transformed `let:` directives */
readonly let_directives: Statement[]; readonly let_directives: Statement[];
/** Memoized expressions */ /** Memoized expressions */

@ -24,15 +24,15 @@ export function ConstTag(node, context) {
expression = b.call('$.tag', expression, b.literal(declaration.id.name)); expression = b.call('$.tag', expression, b.literal(declaration.id.name));
} }
context.state.consts.push(b.const(declaration.id, expression));
context.state.transform[declaration.id.name] = { read: get_value }; context.state.transform[declaration.id.name] = { read: get_value };
// we need to eagerly evaluate the expression in order to hit any add_const_declaration(
// 'Cannot access x before initialization' errors context.state,
if (dev) { declaration.id,
context.state.consts.push(b.stmt(b.call('$.get', declaration.id))); expression,
} node.metadata.expression.has_await,
context.state.scope.get_bindings(declaration)
);
} else { } else {
const identifiers = extract_identifiers(declaration.id); const identifiers = extract_identifiers(declaration.id);
const tmp = b.id(context.state.scope.generate('computed_const')); const tmp = b.id(context.state.scope.generate('computed_const'));
@ -69,13 +69,13 @@ export function ConstTag(node, context) {
expression = b.call('$.tag', expression, b.literal('[@const]')); expression = b.call('$.tag', expression, b.literal('[@const]'));
} }
context.state.consts.push(b.const(tmp, expression)); add_const_declaration(
context.state,
// we need to eagerly evaluate the expression in order to hit any tmp,
// 'Cannot access x before initialization' errors expression,
if (dev) { node.metadata.expression.has_await,
context.state.consts.push(b.stmt(b.call('$.get', tmp))); context.state.scope.get_bindings(declaration)
} );
for (const node of identifiers) { for (const node of identifiers) {
context.state.transform[node.name] = { context.state.transform[node.name] = {
@ -84,3 +84,39 @@ export function ConstTag(node, context) {
} }
} }
} }
/**
* @param {ComponentContext['state']} state
* @param {import('estree').Identifier} id
* @param {import('estree').Expression} expression
* @param {boolean} has_await
* @param {import('#compiler').Binding[]} bindings
*/
function add_const_declaration(state, id, expression, has_await, bindings) {
// we need to eagerly evaluate the expression in order to hit any
// 'Cannot access x before initialization' errors
const after = dev ? [b.stmt(b.call('$.get', id))] : [];
if (has_await || state.async_consts) {
const run = (state.async_consts ??= {
id: b.id(state.scope.generate('promises')),
thunks: []
});
state.consts.push(b.let(id));
const assignment = b.assignment('=', id, expression);
const body = after.length === 0 ? assignment : b.block([b.stmt(assignment), ...after]);
run.thunks.push(b.thunk(body, has_await));
const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
} else {
state.consts.push(b.const(id, expression));
state.consts.push(...after);
}
}

@ -48,8 +48,6 @@ export function Fragment(node, context) {
const is_single_child_not_needing_template = const is_single_child_not_needing_template =
trimmed.length === 1 && trimmed.length === 1 &&
(trimmed[0].type === 'SvelteFragment' || trimmed[0].type === 'TitleElement'); (trimmed[0].type === 'SvelteFragment' || trimmed[0].type === 'TitleElement');
const has_await = context.state.init !== null && (node.metadata.has_await || false);
const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent
/** @type {Statement[]} */ /** @type {Statement[]} */
@ -72,7 +70,8 @@ export function Fragment(node, context) {
metadata: { metadata: {
namespace, namespace,
bound_contenteditable: context.state.metadata.bound_contenteditable bound_contenteditable: context.state.metadata.bound_contenteditable
} },
async_consts: undefined
}; };
for (const node of hoisted) { for (const node of hoisted) {
@ -153,8 +152,8 @@ export function Fragment(node, context) {
body.push(...state.let_directives, ...state.consts); body.push(...state.let_directives, ...state.consts);
if (has_await) { if (state.async_consts && state.async_consts.thunks.length > 0) {
body.push(b.if(b.call('$.aborted'), b.return())); body.push(b.var(state.async_consts.id, b.call('$.run', b.array(state.async_consts.thunks))));
} }
if (is_text_first) { if (is_text_first) {
@ -177,13 +176,5 @@ export function Fragment(node, context) {
body.push(close); body.push(close);
} }
if (has_await) { return b.block(body);
return b.block([
b.stmt(
b.call('$.async_body', b.id('$$anchor'), b.arrow([b.id('$$anchor')], b.block(body), true))
)
]);
} else {
return b.block(body);
}
} }

@ -14,8 +14,6 @@ export function SnippetBlock(node, context) {
// TODO hoist where possible // TODO hoist where possible
/** @type {(Identifier | AssignmentPattern)[]} */ /** @type {(Identifier | AssignmentPattern)[]} */
const args = [b.id('$$anchor')]; const args = [b.id('$$anchor')];
const has_await = node.body.metadata.has_await || false;
/** @type {BlockStatement} */ /** @type {BlockStatement} */
let body; let body;
@ -78,12 +76,8 @@ export function SnippetBlock(node, context) {
// in dev we use a FunctionExpression (not arrow function) so we can use `arguments` // in dev we use a FunctionExpression (not arrow function) so we can use `arguments`
let snippet = dev let snippet = dev
? b.call( ? b.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body))
'$.wrap_snippet', : b.arrow(args, body);
b.id(context.state.analysis.name),
b.function(null, args, body, has_await)
)
: b.arrow(args, body, has_await);
const declaration = b.const(node.expression, snippet); const declaration = b.const(node.expression, snippet);

@ -48,7 +48,11 @@ export function SvelteBoundary(node, context) {
if (child.type === 'ConstTag') { if (child.type === 'ConstTag') {
has_const = true; has_const = true;
if (!context.state.options.experimental.async) { if (!context.state.options.experimental.async) {
context.visit(child, { ...context.state, consts: const_tags }); context.visit(child, {
...context.state,
consts: const_tags,
scope: context.state.scopes.get(node.fragment) ?? context.state.scope
});
} }
} }
} }
@ -101,7 +105,13 @@ export function SvelteBoundary(node, context) {
nodes.push(child); nodes.push(child);
} }
const block = /** @type {BlockStatement} */ (context.visit({ ...node.fragment, nodes })); const block = /** @type {BlockStatement} */ (
context.visit(
{ ...node.fragment, nodes },
// Since we're creating a new fragment the reference in scopes can't match, so we gotta attach the right scope manually
{ ...context.state, scope: context.state.scopes.get(node.fragment) ?? context.state.scope }
)
);
if (!context.state.options.experimental.async) { if (!context.state.options.experimental.async) {
block.body.unshift(...const_tags); block.body.unshift(...const_tags);

@ -105,7 +105,7 @@ export function process_children(nodes, initial, is_element, context) {
is_element && is_element &&
// In case it's wrapped in async the async logic will want to skip sibling nodes up until the end, hence we cannot make this controlled // In case it's wrapped in async the async logic will want to skip sibling nodes up until the end, hence we cannot make this controlled
// TODO switch this around and instead optimize for elements with a single block child and not require extra comments (neither for async nor normally) // TODO switch this around and instead optimize for elements with a single block child and not require extra comments (neither for async nor normally)
!(node.body.metadata.has_await || node.metadata.expression.is_async()) !node.metadata.expression.is_async()
) { ) {
node.metadata.is_controlled = true; node.metadata.is_controlled = true;
} else { } else {

@ -1,4 +1,10 @@
import type { Expression, Statement, ModuleDeclaration, LabeledStatement } from 'estree'; import type {
Expression,
Statement,
ModuleDeclaration,
LabeledStatement,
Identifier
} from 'estree';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js'; import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js'; import type { ComponentAnalysis } from '../../types.js';
@ -21,6 +27,11 @@ export interface ComponentServerTransformState extends ServerTransformState {
readonly namespace: Namespace; readonly namespace: Namespace;
readonly preserve_whitespace: boolean; readonly preserve_whitespace: boolean;
readonly skip_hydration_boundaries: boolean; readonly skip_hydration_boundaries: boolean;
/** Transformed async `{@const }` declarations (if any) and those coming after them */
async_consts?: {
id: Identifier;
thunks: Expression[];
};
} }
export type Context = import('zimmerframe').Context<AST.SvelteNode, ServerTransformState>; export type Context = import('zimmerframe').Context<AST.SvelteNode, ServerTransformState>;

@ -2,6 +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 { extract_identifiers } from '../../../../utils/ast.js';
/** /**
* @param {AST.ConstTag} node * @param {AST.ConstTag} node
@ -11,6 +12,29 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0]; const declaration = node.declaration.declarations[0];
const id = /** @type {Pattern} */ (context.visit(declaration.id)); const id = /** @type {Pattern} */ (context.visit(declaration.id));
const init = /** @type {Expression} */ (context.visit(declaration.init)); const init = /** @type {Expression} */ (context.visit(declaration.init));
const has_await = node.metadata.expression.has_await;
context.state.init.push(b.const(id, init)); if (has_await || context.state.async_consts) {
const run = (context.state.async_consts ??= {
id: b.id(context.state.scope.generate('promises')),
thunks: []
});
const identifiers = extract_identifiers(declaration.id);
const bindings = context.state.scope.get_bindings(declaration);
for (const identifier of identifiers) {
context.state.init.push(b.let(identifier.name));
}
const assignment = b.assignment('=', id, init);
run.thunks.push(b.thunk(b.block([b.stmt(assignment)]), has_await));
const blocker = b.member(run.id, b.literal(run.thunks.length - 1), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
} else {
context.state.init.push(b.const(id, init));
}
} }

@ -34,11 +34,7 @@ export function EachBlock(node, context) {
const new_body = /** @type {BlockStatement} */ (context.visit(node.body)).body; const new_body = /** @type {BlockStatement} */ (context.visit(node.body)).body;
if (node.body) if (node.body) each.push(...new_body);
each.push(
// TODO get rid of fragment.has_await
...(node.body.metadata.has_await ? [create_async_block(b.block(new_body))] : new_body)
);
const for_loop = b.for( const for_loop = b.for(
b.declaration('let', [ b.declaration('let', [
@ -61,7 +57,7 @@ export function EachBlock(node, context) {
b.if( b.if(
b.binary('!==', b.member(array_id, 'length'), b.literal(0)), b.binary('!==', b.member(array_id, 'length'), b.literal(0)),
b.block([open, for_loop]), b.block([open, for_loop]),
node.fallback.metadata.has_await ? create_async_block(fallback) : fallback fallback
) )
); );
} else { } else {

@ -28,7 +28,8 @@ export function Fragment(node, context) {
init: [], init: [],
template: [], template: [],
namespace, namespace,
skip_hydration_boundaries: is_standalone skip_hydration_boundaries: is_standalone,
async_consts: undefined
}; };
for (const node of hoisted) { for (const node of hoisted) {
@ -42,5 +43,11 @@ export function Fragment(node, context) {
process_children(trimmed, { ...context, state }); process_children(trimmed, { ...context, state });
if (state.async_consts && state.async_consts.thunks.length > 0) {
state.init.push(
b.var(state.async_consts.id, b.call('$$renderer.run', b.array(state.async_consts.thunks)))
);
}
return b.block([...state.init, ...build_template(state.template)]); return b.block([...state.init, ...build_template(state.template)]);
} }

@ -25,11 +25,7 @@ export function IfBlock(node, context) {
const is_async = node.metadata.expression.is_async(); const is_async = node.metadata.expression.is_async();
const has_await = const has_await = node.metadata.expression.has_await;
node.metadata.expression.has_await ||
// TODO get rid of this stuff
node.consequent.metadata.has_await ||
node.alternate?.metadata.has_await;
if (is_async || has_await) { if (is_async || has_await) {
statement = create_async_block( statement = create_async_block(

@ -3,7 +3,6 @@
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import { dev } from '../../../../state.js'; import { dev } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { create_async_block } from './shared/utils.js';
/** /**
* @param {AST.SnippetBlock} node * @param {AST.SnippetBlock} node
@ -16,10 +15,6 @@ export function SnippetBlock(node, context) {
/** @type {BlockStatement} */ (context.visit(node.body)) /** @type {BlockStatement} */ (context.visit(node.body))
); );
if (node.body.metadata.has_await) {
fn.body = b.block([create_async_block(fn.body)]);
}
// @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone // @ts-expect-error - TODO remove this hack once $$render_inner for legacy bindings is gone
fn.___snippet = true; fn.___snippet = true;

@ -7,8 +7,7 @@ import {
block_open, block_open,
block_open_else, block_open_else,
build_attribute_value, build_attribute_value,
build_template, build_template
create_async_block
} from './shared/utils.js'; } from './shared/utils.js';
/** /**
@ -43,14 +42,11 @@ export function SvelteBoundary(node, context) {
); );
const pending = b.call(callee, b.id('$$renderer')); const pending = b.call(callee, b.id('$$renderer'));
const block = /** @type {BlockStatement} */ (context.visit(node.fragment)); const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
const statement = node.fragment.metadata.has_await
? create_async_block(b.block([block]))
: block;
context.state.template.push( context.state.template.push(
b.if( b.if(
callee, callee,
b.block(build_template([block_open_else, b.stmt(pending), block_close])), b.block(build_template([block_open_else, b.stmt(pending), block_close])),
b.block(build_template([block_open, statement, block_close])) b.block(build_template([block_open, block, block_close]))
) )
); );
} else { } else {
@ -70,9 +66,6 @@ export function SvelteBoundary(node, context) {
} }
} else { } else {
const block = /** @type {BlockStatement} */ (context.visit(node.fragment)); const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
const statement = node.fragment.metadata.has_await context.state.template.push(block_open, block, block_close);
? create_async_block(b.block([block]))
: block;
context.state.template.push(block_open, statement, block_close);
} }
} }

@ -244,12 +244,7 @@ export function build_inline_component(node, expression, context) {
params.push(pattern); params.push(pattern);
} }
const slot_fn = b.arrow( const slot_fn = b.arrow(params, b.block(block.body));
params,
node.fragment.metadata.has_await
? b.block([create_async_block(b.block(block.body))])
: b.block(block.body)
);
if (slot_name === 'default' && !has_children_prop) { if (slot_name === 'default' && !has_children_prop) {
if ( if (

@ -48,8 +48,6 @@ export namespace AST {
* Whether or not we need to traverse into the fragment during mount/hydrate * Whether or not we need to traverse into the fragment during mount/hydrate
*/ */
dynamic: boolean; dynamic: boolean;
/** @deprecated we should get rid of this in favour of the `$$renderer.run` mechanism */
has_await: boolean;
}; };
} }

@ -2,11 +2,12 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
html: `<h1>Loading...</h1>`, mode: ['async-server', 'client', 'hydrate'],
ssrHtml: `<h1>Hello, world!</h1> 5 01234 5 sync 6 5 0`,
async test({ assert, target }) { async test({ assert, target }) {
await tick(); await tick();
assert.htmlEqual(target.innerHTML, `<h1>Hello, world!</h1> 5 01234`); assert.htmlEqual(target.innerHTML, `<h1>Hello, world!</h1> 5 01234 5 sync 6 5 0`);
} }
}); });

@ -3,17 +3,16 @@
</script> </script>
<svelte:boundary> <svelte:boundary>
{@const sync = 'sync'}
{@const number = await Promise.resolve(5)} {@const number = await Promise.resolve(5)}
{@const after_async = number + 1}
{#snippet pending()} {@const { length, 0: first } = await '01234'}
<h1>Loading...</h1>
{/snippet}
{#snippet greet()} {#snippet greet()}
{@const greeting = await `Hello, ${name}!`} {@const greeting = await `Hello, ${name}!`}
<h1>{greeting}</h1> <h1>{greeting}</h1>
{number} {number}
{#if number > 4} {#if number > 4 && after_async && greeting}
{@const length = await number} {@const length = await number}
{#each { length }, index} {#each { length }, index}
{@const i = await index} {@const i = await index}
@ -23,4 +22,5 @@
{/snippet} {/snippet}
{@render greet()} {@render greet()}
{number} {sync} {after_async} {length} {first}
</svelte:boundary> </svelte:boundary>

@ -25,20 +25,23 @@ export default function Async_in_derived($$anchor, $$props) {
{ {
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
$.async_body($$anchor, async ($$anchor) => { let yes1;
const yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(); let yes2;
const yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(); let no1;
let no2;
const no1 = $.derived(() => (async () => { var promises = $.run([
return await 1; async () => yes1 = (await $.save($.async_derived(async () => (await $.save(1))())))(),
})()); async () => yes2 = (await $.save($.async_derived(async () => foo((await $.save(1))()))))(),
const no2 = $.derived(() => (async () => { () => no1 = $.derived(() => (async () => {
return await 1; return await 1;
})()); })()),
if ($.aborted()) return; () => no2 = $.derived(() => (async () => {
}); return await 1;
})())
]);
}; };
$.if(node, ($$render) => { $.if(node, ($$render) => {

@ -18,24 +18,38 @@ export default function Async_in_derived($$renderer, $$props) {
} }
]); ]);
$$renderer.async_block([], async ($$renderer) => { if (true) {
if (true) { $$renderer.push('<!--[-->');
$$renderer.push('<!--[-->');
let yes1;
const yes1 = (await $.save(1))(); let yes2;
const yes2 = foo((await $.save(1))()); let no1;
let no2;
const no1 = (async () => {
return await 1; var promises = $$renderer.run([
})(); async () => {
yes1 = (await $.save(1))();
const no2 = (async () => { },
return await 1;
})(); async () => {
} else { yes2 = foo((await $.save(1))());
$$renderer.push('<!--[!-->'); },
}
}); () => {
no1 = (async () => {
return await 1;
})();
},
() => {
no2 = (async () => {
return await 1;
})();
}
]);
} else {
$$renderer.push('<!--[!-->');
}
$$renderer.push(`<!--]-->`); $$renderer.push(`<!--]-->`);
}); });

Loading…
Cancel
Save