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 2 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: [],
metadata: {
transparent,
dynamic: false,
has_await: false
dynamic: false
}
};
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -2,11 +2,12 @@ import { tick } from 'svelte';
import { test } from '../../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 }) {
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>
<svelte:boundary>
{@const sync = 'sync'}
{@const number = await Promise.resolve(5)}
{#snippet pending()}
<h1>Loading...</h1>
{/snippet}
{@const after_async = number + 1}
{@const { length, 0: first } = await '01234'}
{#snippet greet()}
{@const greeting = await `Hello, ${name}!`}
<h1>{greeting}</h1>
{number}
{#if number > 4}
{#if number > 4 && after_async && greeting}
{@const length = await number}
{#each { length }, index}
{@const i = await index}
@ -23,4 +22,5 @@
{/snippet}
{@render greet()}
{number} {sync} {after_async} {length} {first}
</svelte:boundary>

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

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

Loading…
Cancel
Save