diff --git a/.changeset/brave-baboons-teach.md b/.changeset/brave-baboons-teach.md new file mode 100644 index 000000000000..55d7d2a9884f --- /dev/null +++ b/.changeset/brave-baboons-teach.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow `await` inside `@const` declarations diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/create.js b/packages/svelte/src/compiler/phases/1-parse/utils/create.js index 6030f1bd7bff..2fba918f20ee 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/create.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/create.js @@ -10,7 +10,8 @@ export function create_fragment(transparent = false) { nodes: [], metadata: { transparent, - dynamic: false + dynamic: false, + has_await: false } }; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index ecc843d2f42a..1a7a23370d1a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -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', diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 080239bac063..2d99a2e155f6 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -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. diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index af7d0307e9dc..b2f59b849b42 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -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; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js new file mode 100644 index 000000000000..02d780dc0dc7 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Fragment.js @@ -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 }); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 0737f531a5b4..166207f66aa7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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), diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index e691be169b55..59c024dfb78b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -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 */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index 6d9dac8a33d3..19a4342b5eb1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -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); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index 4246091bcf88..e2e8e93f768c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -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))) ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index 34acdd6bb975..b550dae890db 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -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) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 0b10c02ffbe1..c7c576101e5d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -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); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js index abdbc381d99c..f33febeeb281 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js @@ -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))); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js index 203cf62b3792..895522d47ab2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js @@ -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); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index d37b990440c7..70df0223557d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -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 }); } } diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 058a1a8e66c3..060df2dcb2a4 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -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; }; } diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index defdb1642e1d..56a5f31ffe82 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -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 }; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/_config.js b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js new file mode 100644 index 000000000000..084d9c3874ef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-const/_config.js @@ -0,0 +1,12 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

Loading...

`, + + async test({ assert, target }) { + await tick(); + + assert.htmlEqual(target.innerHTML, `

Hello, world!

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte new file mode 100644 index 000000000000..9321bd792980 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-const/main.svelte @@ -0,0 +1,16 @@ + + + + {#snippet pending()} +

Loading...

+ {/snippet} + + {#snippet greet()} + {@const greeting = await `Hello, ${name}!`} +

{greeting}

+ {/snippet} + + {@render greet()} +