feat: async fragments (#16542)

* init

* fix

* fix

* maybe this works?

* fix, minor cleanup

* maybe this'll fix hydration issues?

* apparently not, maybe this'll do it

* add test, changeset

* minor tweak

* tabs

* avoid timeouts in tests, they add up quickly

* tweak

* fix

* some is more 'correct' than find

* chore: remove `b.async`

* simplify

* apply suggestion from review

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Update .changeset/brave-baboons-teach.md

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/16560/head
ComputerGuy 1 month ago committed by GitHub
parent 41a20aa975
commit 193f23fa05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: allow `await` inside `@const` declarations

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

@ -37,6 +37,7 @@ import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
import { ExportSpecifier } from './visitors/ExportSpecifier.js';
import { ExpressionStatement } from './visitors/ExpressionStatement.js';
import { ExpressionTag } from './visitors/ExpressionTag.js';
import { Fragment } from './visitors/Fragment.js';
import { FunctionDeclaration } from './visitors/FunctionDeclaration.js';
import { FunctionExpression } from './visitors/FunctionExpression.js';
import { HtmlTag } from './visitors/HtmlTag.js';
@ -156,6 +157,7 @@ const visitors = {
ExportSpecifier,
ExpressionStatement,
ExpressionTag,
Fragment,
FunctionDeclaration,
FunctionExpression,
HtmlTag,
@ -300,6 +302,7 @@ export function analyze_module(source, options) {
function_depth: 0,
has_props_rune: false,
options: /** @type {ValidatedCompileOptions} */ (options),
fragment: null,
parent_element: null,
reactive_statement: null
},
@ -687,6 +690,7 @@ export function analyze_component(root, source, options) {
analysis,
options,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
fragment: ast === template.ast ? ast : null,
parent_element: null,
has_props_rune: false,
component_slots: new Set(),
@ -752,6 +756,7 @@ export function analyze_component(root, source, options) {
scopes,
analysis,
options,
fragment: ast === template.ast ? ast : null,
parent_element: null,
has_props_rune: false,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',

@ -8,6 +8,7 @@ export interface AnalysisState {
analysis: ComponentAnalysis;
options: ValidatedCompileOptions;
ast_type: 'instance' | 'template' | 'module';
fragment: AST.Fragment | null;
/**
* Tag name of the parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root.
* Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between.

@ -11,6 +11,15 @@ export function AwaitExpression(node, context) {
if (context.state.expression) {
context.state.expression.has_await = true;
if (
context.state.fragment &&
// TODO there's probably a better way to do this
context.path.some((node) => node.type === 'ConstTag')
) {
context.state.fragment.metadata.has_await = true;
}
suspend = true;
}

@ -0,0 +1,10 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types.js' */
/**
* @param {AST.Fragment} node
* @param {Context} context
*/
export function Fragment(node, context) {
context.next({ ...context.state, fragment: node });
}

@ -172,6 +172,7 @@ export function client_component(analysis, options) {
// these are set inside the `Fragment` visitor, and cannot be used until then
init: /** @type {any} */ (null),
consts: /** @type {any} */ (null),
update: /** @type {any} */ (null),
after_update: /** @type {any} */ (null),
template: /** @type {any} */ (null),

@ -6,7 +6,8 @@ import type {
Expression,
AssignmentExpression,
UpdateExpression,
VariableDeclaration
VariableDeclaration,
Declaration
} from 'estree';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
@ -57,6 +58,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly update: Statement[];
/** Stuff that happens after the render effect (control blocks, dynamic elements, bindings, actions, etc) */
readonly after_update: Statement[];
/** Transformed `{@const }` declarations */
readonly consts: Statement[];
/** Memoized expressions */
readonly memoizer: Memoizer;
/** The HTML template string */

@ -1,4 +1,4 @@
/** @import { ArrowFunctionExpression, AssignmentExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Node, Pattern, UpdateExpression } from 'estree' */
/** @import { ArrowFunctionExpression, AssignmentExpression, BlockStatement, Expression, FunctionDeclaration, FunctionExpression, Identifier, Node, Pattern, UpdateExpression } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */
/** @import { Analysis } from '../../types.js' */
@ -289,8 +289,15 @@ export function should_proxy(node, scope) {
/**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {ComponentClientTransformState} state
* @param {Expression} arg
* @param {Expression | BlockStatement} expression
* @param {boolean} [async]
*/
export function create_derived(state, arg) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', arg);
export function create_derived(state, expression, async = false) {
const thunk = b.thunk(expression, async);
if (async) {
return b.call(b.await(b.call('$.save', b.call('$.async_derived', thunk))));
} else {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
}
}

@ -96,13 +96,13 @@ function create_derived_block_argument(node, context) {
b.return(b.object(identifiers.map((identifier) => b.prop('init', identifier, identifier))))
]);
const declarations = [b.var(value, create_derived(context.state, b.thunk(block)))];
const declarations = [b.var(value, create_derived(context.state, block))];
for (const id of identifiers) {
context.state.transform[id.name] = { read: get_value };
declarations.push(
b.var(id, create_derived(context.state, b.thunk(b.member(b.call('$.get', value), id))))
b.var(id, create_derived(context.state, b.member(b.call('$.get', value), id)))
);
}

@ -16,21 +16,26 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0];
// TODO we can almost certainly share some code with $derived(...)
if (declaration.id.type === 'Identifier') {
const init = build_expression(context, declaration.init, node.metadata.expression);
let expression = create_derived(context.state, b.thunk(init));
const init = build_expression(
{ ...context, state: { ...context.state, in_derived: true } },
declaration.init,
node.metadata.expression
);
let expression = create_derived(context.state, init, node.metadata.expression.has_await);
if (dev) {
expression = b.call('$.tag', expression, b.literal(declaration.id.name));
}
context.state.init.push(b.const(declaration.id, expression));
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.init.push(b.stmt(b.call('$.get', declaration.id)));
context.state.consts.push(b.stmt(b.call('$.get', declaration.id)));
}
} else {
const identifiers = extract_identifiers(declaration.id);
@ -44,7 +49,11 @@ export function ConstTag(node, context) {
delete transform[node.name];
}
const child_state = { ...context.state, transform };
const child_state = /** @type {ComponentContext['state']} */ ({
...context.state,
transform,
in_derived: true
});
// TODO optimise the simple `{ x } = y` case — we can just return `y`
// instead of destructuring it only to return a new object
@ -53,26 +62,24 @@ export function ConstTag(node, context) {
declaration.init,
node.metadata.expression
);
const fn = b.arrow(
[],
b.block([
b.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
])
);
let expression = create_derived(context.state, fn);
const block = b.block([
b.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]);
let expression = create_derived(context.state, block, node.metadata.expression.has_await);
if (dev) {
expression = b.call('$.tag', expression, b.literal('[@const]'));
}
context.state.init.push(b.const(tmp, expression));
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.init.push(b.stmt(b.call('$.get', tmp)));
context.state.consts.push(b.stmt(b.call('$.get', tmp)));
}
for (const node of identifiers) {

@ -48,8 +48,10 @@ 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
const unsuspend = b.id('$$unsuspend');
/** @type {Statement[]} */
const body = [];
@ -61,6 +63,7 @@ export function Fragment(node, context) {
const state = {
...context.state,
init: [],
consts: [],
update: [],
after_update: [],
memoizer: new Memoizer(),
@ -76,11 +79,6 @@ export function Fragment(node, context) {
context.visit(node, state);
}
if (is_text_first) {
// skip over inserted comment
body.push(b.stmt(b.call('$.next')));
}
if (is_single_element) {
const element = /** @type {AST.RegularElement} */ (trimmed[0]);
@ -96,13 +94,13 @@ export function Fragment(node, context) {
const template = transform_template(state, namespace, flags);
state.hoisted.push(b.var(template_name, template));
body.push(b.var(id, b.call(template_name)));
state.init.unshift(b.var(id, b.call(template_name)));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else if (is_single_child_not_needing_template) {
context.visit(trimmed[0], state);
} else if (trimmed.length === 1 && trimmed[0].type === 'Text') {
const id = b.id(context.state.scope.generate('text'));
body.push(b.var(id, b.call('$.text', b.literal(trimmed[0].data))));
state.init.unshift(b.var(id, b.call('$.text', b.literal(trimmed[0].data))));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else if (trimmed.length > 0) {
const id = b.id(context.state.scope.generate('fragment'));
@ -120,7 +118,7 @@ export function Fragment(node, context) {
state
});
body.push(b.var(id, b.call('$.text')));
state.init.unshift(b.var(id, b.call('$.text')));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else {
if (is_standalone) {
@ -140,12 +138,12 @@ export function Fragment(node, context) {
if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') {
// special case — we can use `$.comment` instead of creating a unique template
body.push(b.var(id, b.call('$.comment')));
state.init.unshift(b.var(id, b.call('$.comment')));
} else {
const template = transform_template(state, namespace, flags);
state.hoisted.push(b.var(template_name, template));
body.push(b.var(id, b.call(template_name)));
state.init.unshift(b.var(id, b.call(template_name)));
}
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
@ -153,6 +151,21 @@ export function Fragment(node, context) {
}
}
if (has_await) {
body.push(b.var(unsuspend, b.call('$.suspend')));
}
body.push(...state.consts);
if (has_await) {
body.push(b.if(b.call('$.aborted'), b.return()));
}
if (is_text_first) {
// skip over inserted comment
body.push(b.stmt(b.call('$.next')));
}
body.push(...state.init);
if (state.update.length > 0) {
@ -168,5 +181,9 @@ export function Fragment(node, context) {
body.push(close);
}
if (has_await) {
body.push(b.stmt(b.call(unsuspend)));
}
return b.block(body);
}

@ -46,9 +46,6 @@ export function LetDirective(node, context) {
read: (node) => b.call('$.get', node)
};
return b.const(
name,
create_derived(context.state, b.thunk(b.member(b.id('$$slotProps'), node.name)))
);
return b.const(name, create_derived(context.state, b.member(b.id('$$slotProps'), node.name)));
}
}

@ -14,6 +14,7 @@ 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;
@ -21,10 +22,6 @@ export function SnippetBlock(node, context) {
/** @type {Statement[]} */
const declarations = [];
if (dev) {
declarations.push(b.stmt(b.call('$.validate_snippet_args', b.spread(b.id('arguments')))));
}
const transform = { ...context.state.transform };
const child_state = { ...context.state, transform };
@ -72,16 +69,21 @@ export function SnippetBlock(node, context) {
}
}
}
const block = /** @type {BlockStatement} */ (context.visit(node.body, child_state)).body;
body = b.block([
dev ? b.stmt(b.call('$.validate_snippet_args', b.spread(b.id('arguments')))) : b.empty,
...declarations,
.../** @type {BlockStatement} */ (context.visit(node.body, child_state)).body
...block
]);
// 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))
: b.arrow(args, body);
? b.call(
'$.wrap_snippet',
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);

@ -43,7 +43,7 @@ export function SvelteBoundary(node, context) {
// to resolve this we cheat: we duplicate const tags inside snippets
for (const child of node.fragment.nodes) {
if (child.type === 'ConstTag') {
context.visit(child, { ...context.state, init: const_tags });
context.visit(child, { ...context.state, consts: const_tags });
}
}

@ -56,6 +56,7 @@ export namespace AST {
* Whether or not we need to traverse into the fragment during mount/hydrate
*/
dynamic: boolean;
has_await: boolean;
};
}

@ -588,14 +588,14 @@ export function method(kind, key, params, body, computed = false, is_static = fa
* @param {ESTree.BlockStatement} body
* @returns {ESTree.FunctionExpression}
*/
function function_builder(id, params, body) {
function function_builder(id, params, body, async = false) {
return {
type: 'FunctionExpression',
id,
params,
body,
generator: false,
async: false,
async,
metadata: /** @type {any} */ (null) // should not be used by codegen
};
}

@ -0,0 +1,12 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `<h1>Loading...</h1>`,
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, `<h1>Hello, world!</h1>`);
}
});

@ -0,0 +1,16 @@
<script>
let name = $state('world');
</script>
<svelte:boundary>
{#snippet pending()}
<h1>Loading...</h1>
{/snippet}
{#snippet greet()}
{@const greeting = await `Hello, ${name}!`}
<h1>{greeting}</h1>
{/snippet}
{@render greet()}
</svelte:boundary>
Loading…
Cancel
Save