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

@ -8,6 +8,7 @@ export interface AnalysisState {
analysis: ComponentAnalysis; analysis: ComponentAnalysis;
options: ValidatedCompileOptions; options: ValidatedCompileOptions;
ast_type: 'instance' | 'template' | 'module'; 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. * 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. * 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) { if (context.state.expression) {
context.state.expression.has_await = true; 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; 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 // these are set inside the `Fragment` visitor, and cannot be used until then
init: /** @type {any} */ (null), init: /** @type {any} */ (null),
consts: /** @type {any} */ (null),
update: /** @type {any} */ (null), update: /** @type {any} */ (null),
after_update: /** @type {any} */ (null), after_update: /** @type {any} */ (null),
template: /** @type {any} */ (null), template: /** @type {any} */ (null),

@ -6,7 +6,8 @@ import type {
Expression, Expression,
AssignmentExpression, AssignmentExpression,
UpdateExpression, UpdateExpression,
VariableDeclaration VariableDeclaration,
Declaration
} from 'estree'; } 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';
@ -57,6 +58,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly update: Statement[]; readonly update: Statement[];
/** Stuff that happens after the render effect (control blocks, dynamic elements, bindings, actions, etc) */ /** Stuff that happens after the render effect (control blocks, dynamic elements, bindings, actions, etc) */
readonly after_update: Statement[]; readonly after_update: Statement[];
/** Transformed `{@const }` declarations */
readonly consts: Statement[];
/** Memoized expressions */ /** Memoized expressions */
readonly memoizer: Memoizer; readonly memoizer: Memoizer;
/** The HTML template string */ /** 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 { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */ /** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */
/** @import { Analysis } 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 * Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {ComponentClientTransformState} state * @param {ComponentClientTransformState} state
* @param {Expression} arg * @param {Expression | BlockStatement} expression
* @param {boolean} [async]
*/ */
export function create_derived(state, arg) { export function create_derived(state, expression, async = false) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', arg); 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)))) 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) { for (const id of identifiers) {
context.state.transform[id.name] = { read: get_value }; context.state.transform[id.name] = { read: get_value };
declarations.push( 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]; const declaration = node.declaration.declarations[0];
// TODO we can almost certainly share some code with $derived(...) // TODO we can almost certainly share some code with $derived(...)
if (declaration.id.type === 'Identifier') { if (declaration.id.type === 'Identifier') {
const init = build_expression(context, declaration.init, node.metadata.expression); const init = build_expression(
let expression = create_derived(context.state, b.thunk(init)); { ...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) { if (dev) {
expression = b.call('$.tag', expression, b.literal(declaration.id.name)); 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 }; context.state.transform[declaration.id.name] = { read: get_value };
// we need to eagerly evaluate the expression in order to hit any // we need to eagerly evaluate the expression in order to hit any
// 'Cannot access x before initialization' errors // 'Cannot access x before initialization' errors
if (dev) { 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 { } else {
const identifiers = extract_identifiers(declaration.id); const identifiers = extract_identifiers(declaration.id);
@ -44,7 +49,11 @@ export function ConstTag(node, context) {
delete transform[node.name]; 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` // TODO optimise the simple `{ x } = y` case — we can just return `y`
// instead of destructuring it only to return a new object // instead of destructuring it only to return a new object
@ -53,26 +62,24 @@ export function ConstTag(node, context) {
declaration.init, declaration.init,
node.metadata.expression 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) { if (dev) {
expression = b.call('$.tag', expression, b.literal('[@const]')); 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 // we need to eagerly evaluate the expression in order to hit any
// 'Cannot access x before initialization' errors // 'Cannot access x before initialization' errors
if (dev) { 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) { for (const node of identifiers) {

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

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

@ -14,6 +14,7 @@ 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;
@ -21,10 +22,6 @@ export function SnippetBlock(node, context) {
/** @type {Statement[]} */ /** @type {Statement[]} */
const declarations = []; const declarations = [];
if (dev) {
declarations.push(b.stmt(b.call('$.validate_snippet_args', b.spread(b.id('arguments')))));
}
const transform = { ...context.state.transform }; const transform = { ...context.state.transform };
const child_state = { ...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([ body = b.block([
dev ? b.stmt(b.call('$.validate_snippet_args', b.spread(b.id('arguments')))) : b.empty,
...declarations, ...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` // in dev we use a FunctionExpression (not arrow function) so we can use `arguments`
let snippet = dev let snippet = dev
? b.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body)) ? b.call(
: b.arrow(args, body); '$.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); 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 // to resolve this we cheat: we duplicate const tags inside snippets
for (const child of node.fragment.nodes) { for (const child of node.fragment.nodes) {
if (child.type === 'ConstTag') { 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 * Whether or not we need to traverse into the fragment during mount/hydrate
*/ */
dynamic: boolean; 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 * @param {ESTree.BlockStatement} body
* @returns {ESTree.FunctionExpression} * @returns {ESTree.FunctionExpression}
*/ */
function function_builder(id, params, body) { function function_builder(id, params, body, async = false) {
return { return {
type: 'FunctionExpression', type: 'FunctionExpression',
id, id,
params, params,
body, body,
generator: false, generator: false,
async: false, async,
metadata: /** @type {any} */ (null) // should not be used by codegen 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