feat: allow declarations in the template

Allows `{let/var/const/function ...}` declarations in all places where we already allow `{@const ...}` (which will eventually get deprecated in favor of this new feature).
pull/18312/head
Simon Holthausen 2 months ago
parent fe9ab936b6
commit f094ac5346
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: allow declarations in the template

@ -0,0 +1,32 @@
---
title: {<declaration ...>}
---
Declaration tags define local variables and functions inside markup.
You can use `let`, `const`, `var` and `function` declarations:
```svelte
{#each boxes as box}
{const area = box.width * box.height}
{function label(value) {
return `${value} square pixels`;
}}
<p>{label(area)}</p>
{/each}
```
Unlike [`{@const ...}`](@const), declaration tags are plain JavaScript declarations. This means `{const ...}` is not reactive by itself; use runes such as `$state` or `$derived` when you need reactive values:
```svelte
{#if user}
{let name = $state(user.name)}
{let greeting = $derived(`Hello ${name}`)}
<input bind:value={name} />
<p>{greeting}</p>
{/if}
```
Declaration tags are only allowed as an immediate child of a block — `{#if ...}`, `{#each ...}`, `{#snippet ...}` and so on — a `<Component />` or a `<svelte:boundary>`.

@ -399,6 +399,18 @@ Invalid selector
Cannot declare a variable with the same name as an import from `<script module>`
```
### declaration_tag_invalid_placement
```
Declaration tags must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, `<svelte:fragment>`, `<svelte:boundary>` or `<Component>`
```
### declaration_tag_invalid_type
```
Declaration tags must be `let`, `const`, `var` or `function` declarations
```
### derived_invalid_export
```

@ -191,6 +191,14 @@ The same applies to components:
> {@debug ...} arguments must be identifiers, not arbitrary expressions
## declaration_tag_invalid_placement
> Declaration tags must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, `<svelte:fragment>`, `<svelte:boundary>` or `<Component>`
## declaration_tag_invalid_type
> Declaration tags must be `let`, `const`, `var` or `function` declarations
## directive_invalid_value
> Directive value must be a JavaScript expression enclosed in curly braces

@ -1004,6 +1004,24 @@ export function debug_tag_invalid_arguments(node) {
e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`);
}
/**
* Declaration tags must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, `<svelte:fragment>`, `<svelte:boundary>` or `<Component>`
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_invalid_placement(node) {
e(node, 'declaration_tag_invalid_placement', `Declaration tags must be the immediate child of \`{#snippet}\`, \`{#if}\`, \`{:else if}\`, \`{:else}\`, \`{#each}\`, \`{:then}\`, \`{:catch}\`, \`<svelte:fragment>\`, \`<svelte:boundary>\` or \`<Component>\`\nhttps://svelte.dev/e/declaration_tag_invalid_placement`);
}
/**
* Declaration tags must be `let`, `const`, `var` or `function` declarations
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_invalid_type(node) {
e(node, 'declaration_tag_invalid_type', `Declaration tags must be \`let\`, \`const\`, \`var\` or \`function\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`);
}
/**
* Directive value must be a JavaScript expression enclosed in curly braces
* @param {null | number | NodeLike} node

@ -262,6 +262,10 @@ export function convert(source, ast) {
};
},
// @ts-ignore
DeclarationTag(node) {
return node;
},
// @ts-ignore
KeyBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes);
return {

@ -1,10 +1,11 @@
/** @import { Comment, Program } from 'estree' */
/** @import { Comment, Program, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from './index.js' */
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
import * as e from '../../errors.js';
import { find_matching_bracket } from './utils/bracket.js';
const JSParser = acorn.Parser;
const TSParser = JSParser.extend(tsPlugin());
@ -98,6 +99,44 @@ export function parse_expression_at(parser, source, index) {
}
}
/**
* @param {Parser} parser
* @param {string} source
* @param {number} index
* @returns {Statement}
*/
export function parse_statement_at(parser, source, index) {
const acorn = parser.ts ? TSParser : JSParser;
const end = find_matching_bracket(source, index, '{');
if (end === undefined) e.unexpected_eof(source.length);
const padded_source = `${' '.repeat(index)}${source.slice(index, end)};`;
const { onComment, add_comments } = get_comment_handlers(
padded_source,
parser.root.comments,
index
);
try {
const ast = acorn.parse(padded_source, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true
});
add_comments(ast);
const statement = /** @type {Statement} */ (
/** @type {unknown} */ (/** @type {Program} */ (ast).body[0])
);
statement.end = Math.min(/** @type {number} */ (statement.end), end);
return statement;
} catch (e) {
handle_parse_error(e);
}
}
const regex_position_indicator = / \(\d+:\d+\)$/;
/**

@ -4,13 +4,15 @@
import { walk } from 'zimmerframe';
import * as e from '../../../errors.js';
import { ExpressionMetadata } from '../../nodes.js';
import { parse_expression_at } from '../acorn.js';
import { parse_expression_at, parse_statement_at } from '../acorn.js';
import read_pattern from '../read/context.js';
import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js';
import { find_matching_bracket, match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /\s*}/y;
const regex_supported_declaration = /(?:let|const|var|function)\b/y;
const regex_unsupported_declaration = /(?:class|type|interface|enum)\b/y;
const pointy_bois = { '<': '>' };
@ -31,6 +33,23 @@ export default function tag(parser) {
}
}
const declaration = read_declaration(parser);
if (declaration) {
parser.append({
type: 'DeclarationTag',
start,
end: parser.index,
declaration:
/** @type {import('estree').VariableDeclaration | import('estree').FunctionDeclaration} */ (
declaration
),
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
const expression = read_expression(parser);
parser.allow_whitespace();
@ -47,6 +66,103 @@ export default function tag(parser) {
});
}
/**
* @param {Parser} parser
* @returns {null | import('estree').VariableDeclaration | import('estree').FunctionDeclaration}
*/
function read_declaration(parser) {
const start = parser.index;
if (parser.match_regex(regex_unsupported_declaration)) {
e.declaration_tag_invalid_type({ start, end: start + 5 });
}
if (!parser.match_regex(regex_supported_declaration)) {
return null;
}
/** @type {import('estree').Statement | import('estree').VariableDeclaration | import('estree').FunctionDeclaration} */
let declaration;
try {
declaration = parse_statement_at(parser, parser.template, start);
} catch (error) {
if (!parser.loose) throw error;
const end = find_matching_bracket(parser.template, start, '{');
if (end === undefined) throw error;
parser.index = end;
if (parser.template.startsWith('function', start)) {
declaration = {
type: 'FunctionDeclaration',
id: {
type: 'Identifier',
name: '',
start: parser.index,
end: parser.index
},
generator: false,
async: false,
params: [],
body: {
type: 'BlockStatement',
body: [],
start: parser.index,
end: parser.index
},
start,
end
};
} else {
const kind = parser.template.startsWith('const', start)
? 'const'
: parser.template.startsWith('var', start)
? 'var'
: 'let';
declaration = {
type: 'VariableDeclaration',
kind,
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: '',
start: parser.index,
end: parser.index
},
init: null,
start: parser.index,
end: parser.index
}
],
start,
end
};
}
}
if (declaration.type !== 'VariableDeclaration' && declaration.type !== 'FunctionDeclaration') {
e.declaration_tag_invalid_type({
start: declaration.start ?? start,
end: declaration.end ?? parser.index
});
}
// TODO support using
if (declaration.type === 'VariableDeclaration' && declaration.kind === 'using') {
e.declaration_tag_invalid_type(declaration);
}
parser.index = /** @type {number} */ (declaration.end);
parser.eat(';');
parser.allow_whitespace();
parser.eat('}', true);
return declaration;
}
/** @param {Parser} parser */
function open(parser) {
let start = parser.index - 2;

@ -36,6 +36,7 @@ import { ClassDeclaration } from './visitors/ClassDeclaration.js';
import { ClassDirective } from './visitors/ClassDirective.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js';
import { ExportDefaultDeclaration } from './visitors/ExportDefaultDeclaration.js';
@ -157,6 +158,7 @@ const visitors = {
ClassDirective,
Component,
ConstTag,
DeclarationTag,
DebugTag,
EachBlock,
ExportDefaultDeclaration,

@ -35,7 +35,7 @@ export interface AnalysisState {
*/
derived_function_depth: number;
/** Collected info about async `{@const }` declarations */
/** Collected info about async `{@const }`/`{let/const/var/ ...}` declarations */
async_consts?: {
id: Identifier;
/** How many `$.run(...)` entries are already allocated in this scope */

@ -1,8 +1,8 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import * as b from '#compiler/builders';
import { validate_opening_tag } from './shared/utils.js';
import { validate_opening_tag, validate_tag_placement } from './shared/utils.js';
import { mark_async_declaration } from './DeclarationTag.js';
/**
* @param {AST.ConstTag} node
@ -13,25 +13,7 @@ export function ConstTag(node, context) {
validate_opening_tag(node, context.state, '@');
}
const parent = context.path.at(-1);
const grand_parent = context.path.at(-2);
if (
parent?.type !== 'Fragment' ||
(grand_parent?.type !== 'IfBlock' &&
grand_parent?.type !== 'SvelteFragment' &&
grand_parent?.type !== 'Component' &&
grand_parent?.type !== 'SvelteComponent' &&
grand_parent?.type !== 'EachBlock' &&
grand_parent?.type !== 'AwaitBlock' &&
grand_parent?.type !== 'SnippetBlock' &&
grand_parent?.type !== 'SvelteBoundary' &&
grand_parent?.type !== 'KeyBlock' &&
((grand_parent?.type !== 'RegularElement' && grand_parent?.type !== 'SvelteElement') ||
!grand_parent.attributes.some((a) => a.type === 'Attribute' && a.name === 'slot')))
) {
e.const_tag_invalid_placement(node);
}
validate_tag_placement(node, context, e.const_tag_invalid_placement);
const declaration = node.declaration.declarations[0];
@ -44,28 +26,5 @@ export function ConstTag(node, context) {
derived_function_depth: context.state.function_depth + 1
});
const has_await = node.metadata.expression.has_await;
const blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (has_await || context.state.async_consts || blockers.length > 0) {
const run = (context.state.async_consts ??= {
id: context.state.analysis.root.unique('promises'),
declaration_count: 0
});
node.metadata.promises_id = run.id;
const bindings = context.state.scope.get_bindings(declaration);
// keep the counter in sync with the number of thunks pushed in ConstTag in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
}
mark_async_declaration(context, node.metadata, [declaration]);
}

@ -0,0 +1,65 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import * as b from '#compiler/builders';
import { validate_opening_tag, validate_tag_placement } from './shared/utils.js';
/**
* @param {AST.DeclarationTag} node
* @param {Context} context
*/
export function DeclarationTag(node, context) {
if (context.state.analysis.runes) {
const expected =
node.declaration.type === 'FunctionDeclaration' ? 'f' : node.declaration.kind[0];
validate_opening_tag(node, context.state, expected);
}
validate_tag_placement(node, context, e.declaration_tag_invalid_placement);
if (node.declaration.type !== 'VariableDeclaration') {
context.visit(node.declaration);
return;
}
context.visit(node.declaration, {
...context.state,
expression: node.metadata.expression
});
mark_async_declaration(context, node.metadata, node.declaration.declarations);
}
/**
* @param {Context} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {import('estree').VariableDeclarator[]} declarations
*/
export function mark_async_declaration(context, metadata, declarations) {
const has_await = metadata.expression.has_await;
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (has_await || context.state.async_consts || blockers.length > 0) {
const run = (context.state.async_consts ??= {
id: context.state.analysis.root.unique('promises'),
declaration_count: 0
});
metadata.promises_id = run.id;
const bindings = declarations.flatMap((declaration) =>
context.state.scope.get_bindings(declaration)
);
// keep the counter in sync with the number of thunks pushed in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
}
}

@ -162,7 +162,7 @@ export function Identifier(node, context) {
if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) {
let snippet_name;
// Find out if this references a {@const ...} declaration of an implicit children snippet
// Find out if this references a {@const ...}/{let/var/const ...} declaration of an implicit children snippet
// when it is itself inside a snippet block at the same level. If so, error.
for (let i = context.path.length - 1; i >= 0; i--) {
const parent = context.path[i];

@ -298,3 +298,30 @@ export function validate_export(node, scope, name) {
e.state_invalid_export(node);
}
}
/**
* @param {AST.ConstTag | AST.DeclarationTag} node
* @param {Context} context
* @param {(node: AST.ConstTag | AST.DeclarationTag) => never} error
*/
export function validate_tag_placement(node, context, error) {
const parent = context.path.at(-1);
const grand_parent = context.path.at(-2);
if (
parent?.type !== 'Fragment' ||
(grand_parent?.type !== 'IfBlock' &&
grand_parent?.type !== 'SvelteFragment' &&
grand_parent?.type !== 'Component' &&
grand_parent?.type !== 'SvelteComponent' &&
grand_parent?.type !== 'EachBlock' &&
grand_parent?.type !== 'AwaitBlock' &&
grand_parent?.type !== 'SnippetBlock' &&
grand_parent?.type !== 'SvelteBoundary' &&
grand_parent?.type !== 'KeyBlock' &&
((grand_parent?.type !== 'RegularElement' && grand_parent?.type !== 'SvelteElement') ||
!grand_parent.attributes.some((a) => a.type === 'Attribute' && a.name === 'slot')))
) {
error(node);
}
}

@ -22,6 +22,7 @@ import { ClassBody } from './visitors/ClassBody.js';
import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js';
import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
@ -99,6 +100,7 @@ const visitors = {
Comment,
Component,
ConstTag,
DeclarationTag,
DebugTag,
EachBlock,
ExportNamedDeclaration,

@ -7,6 +7,7 @@ import * as b from '#compiler/builders';
import { create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
import { add_async_declaration } from './DeclarationTag.js';
/**
* @param {AST.ConstTag} node
@ -26,7 +27,7 @@ export function ConstTag(node, context) {
context.state.transform[declaration.id.name] = { read: get_value };
add_const_declaration(context.state, declaration.id, expression, node.metadata);
add_const_declaration(context, declaration.id, expression, node.metadata);
} else {
const identifiers = extract_identifiers(declaration.id);
const tmp = b.id(context.state.scope.generate('computed_const'));
@ -63,7 +64,7 @@ export function ConstTag(node, context) {
expression = b.call('$.tag', expression, b.literal('[@const]'));
}
add_const_declaration(context.state, tmp, expression, node.metadata);
add_const_declaration(context, tmp, expression, node.metadata);
for (const node of identifiers) {
context.state.transform[node.name] = {
@ -74,38 +75,26 @@ export function ConstTag(node, context) {
}
/**
* @param {ComponentContext['state']} state
* @param {ComponentContext} context
* @param {Identifier} id
* @param {Expression} expression
* @param {AST.ConstTag['metadata']} metadata
*/
function add_const_declaration(state, id, expression, metadata) {
function add_const_declaration(context, id, expression, metadata) {
// 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))] : [];
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== state.async_consts?.id);
if (metadata.promises_id) {
const run = (state.async_consts ??= {
id: metadata.promises_id,
thunks: []
});
state.consts.push(b.let(id));
if (blockers.length === 1) {
run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, expression);
run.thunks.push(b.thunk(assignment, metadata.expression.has_await));
add_async_declaration(
context,
metadata,
[id],
[b.stmt(b.assignment('=', id, expression))],
'let'
);
} else {
const { state } = context;
state.consts.push(b.const(id, expression));
state.consts.push(...after);
}

@ -0,0 +1,86 @@
/** @import { Expression, Identifier, Pattern, Statement, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { add_state_transformers } from './shared/declarations.js';
/**
* @param {AST.DeclarationTag} node
* @param {ComponentContext} context
*/
export function DeclarationTag(node, context) {
const declaration = /** @type {Statement | undefined} */ (context.visit(node.declaration));
add_state_transformers(context);
if (
node.metadata.promises_id &&
node.declaration.type === 'VariableDeclaration' &&
declaration?.type === 'VariableDeclaration'
) {
const { ids, assignments } = build_async_declaration_parts(declaration);
add_async_declaration(context, node.metadata, ids, assignments, declaration.kind);
} else {
context.state.consts.push(declaration ?? node.declaration);
}
}
/**
* @param {VariableDeclaration} declaration
*/
export function build_async_declaration_parts(declaration) {
const ids = new Map();
for (const declarator of declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
ids.set(id.name, id);
}
}
const assignments = declaration.declarations
.filter((declarator) => declarator.init !== null)
.map((declarator) =>
b.stmt(
b.assignment(
'=',
/** @type {Pattern} */ (declarator.id),
/** @type {Expression} */ (declarator.init)
)
)
);
return { ids: [...ids.values()], assignments };
}
/**
* @param {ComponentContext} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {Identifier[]} ids
* @param {Statement[]} assignments
* @param {VariableDeclaration['kind']} [kind]
*/
export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') {
const run = (context.state.async_consts ??= {
id: /** @type {Identifier} */ (metadata.promises_id),
thunks: []
});
for (const id of ids) {
context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
}
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (blockers.length === 1) {
run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
}
// keep the number of thunks pushed in sync with analysis phase
const has_await =
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
run.thunks.push(b.thunk(b.block(assignments), has_await));
}

@ -15,6 +15,7 @@ import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js';
import { ExpressionStatement } from './visitors/ExpressionStatement.js';
@ -64,6 +65,7 @@ const template_visitors = {
AwaitBlock,
Component,
ConstTag,
DeclarationTag,
DebugTag,
EachBlock,
Fragment,

@ -28,7 +28,7 @@ export interface ComponentServerTransformState extends ServerTransformState {
readonly preserve_whitespace: boolean;
/** True if the current node is a) a component or render tag and b) the sole child of a block */
readonly is_standalone: boolean;
/** Transformed async `{@const }` declarations (if any) and those coming after them */
/** Transformed async `{@const }`/`{let/const/var ...}` declarations (if any) and those coming after them */
async_consts?: {
id: Identifier;
thunks: Expression[];

@ -1,8 +1,9 @@
/** @import { Expression, Pattern, Statement } from 'estree' */
/** @import { Expression, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
import { extract_identifiers } from '../../../../utils/ast.js';
import { add_async_declaration } from './DeclarationTag.js';
/**
* @param {AST.ConstTag} node
@ -12,31 +13,15 @@ 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 blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (node.metadata.promises_id) {
const run = (context.state.async_consts ??= {
id: node.metadata.promises_id,
thunks: []
});
const identifiers = extract_identifiers(declaration.id);
for (const identifier of identifiers) {
context.state.init.push(b.let(identifier.name));
}
if (blockers.length === 1) {
run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, init);
run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await));
add_async_declaration(
context,
node.metadata,
extract_identifiers(id),
[b.stmt(b.assignment('=', id, init))],
'let'
);
} else {
context.state.init.push(b.const(id, init));
}

@ -0,0 +1,84 @@
/** @import { Expression, Identifier, Pattern, Statement, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
/**
* @param {AST.DeclarationTag} node
* @param {ComponentContext} context
*/
export function DeclarationTag(node, context) {
const declaration = /** @type {Statement} */ (context.visit(node.declaration));
if (
node.metadata.promises_id &&
node.declaration.type === 'VariableDeclaration' &&
declaration.type === 'VariableDeclaration'
) {
const { ids, assignments } = build_async_declaration_parts(declaration);
add_async_declaration(context, node.metadata, ids, assignments, declaration.kind);
} else {
context.state.init.push(declaration);
}
}
/**
* @param {VariableDeclaration} declaration
*/
export function build_async_declaration_parts(declaration) {
const ids = new Map();
for (const declarator of declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
ids.set(id.name, id);
}
}
const assignments = declaration.declarations
.filter((declarator) => declarator.init !== null)
.map((declarator) =>
b.stmt(
b.assignment(
'=',
/** @type {Pattern} */ (declarator.id),
/** @type {Expression} */ (declarator.init)
)
)
);
return { ids: [...ids.values()], assignments };
}
/**
* @param {ComponentContext} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {Identifier[]} ids
* @param {Statement[]} assignments
* @param {VariableDeclaration['kind']} [kind]
*/
export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') {
const run = (context.state.async_consts ??= {
id: /** @type {Identifier} */ (metadata.promises_id),
thunks: []
});
for (const id of ids) {
context.state.init.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
}
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (blockers.length === 1) {
run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
}
// keep the number of thunks pushed in sync with analysis phase
const has_await =
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
run.thunks.push(b.thunk(b.block(assignments), has_await));
}

@ -152,6 +152,7 @@ export function clean_nodes(
if (
node.type === 'ConstTag' ||
node.type === 'DeclarationTag' ||
node.type === 'DebugTag' ||
node.type === 'SvelteBody' ||
node.type === 'SvelteWindow' ||

@ -1149,8 +1149,19 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
next({ scope });
},
FunctionDeclaration(node, { state, next }) {
if (node.id) state.scope.declare(node.id, 'normal', 'function', node);
FunctionDeclaration(node, { state, path, next }) {
const is_parent_declaration_tag = path.at(-1)?.type === 'DeclarationTag';
if (node.id) {
const binding = state.scope.declare(
node.id,
is_parent_declaration_tag ? 'template' : 'normal',
'function',
node
);
if (is_parent_declaration_tag) {
binding.metadata = { is_template_declaration: true };
}
}
const scope = state.scope.child(true);
scopes.set(node, scope);
@ -1194,7 +1205,8 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
},
VariableDeclaration(node, { state, path, next }) {
const is_parent_const_tag = path.at(-1)?.type === 'ConstTag';
const is_parent_template_tag =
path.at(-1)?.type === 'ConstTag' || path.at(-1)?.type === 'DeclarationTag';
for (const declarator of node.declarations) {
/** @type {Binding[]} */
const bindings = [];
@ -1204,7 +1216,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
for (const id of extract_identifiers(declarator.id)) {
const binding = state.scope.declare(
id,
is_parent_const_tag ? 'template' : 'normal',
is_parent_template_tag ? 'template' : 'normal',
node.kind,
declarator.init
);

@ -603,6 +603,12 @@ const svelte_visitors = (comments) => ({
context.write('}');
},
DeclarationTag(node, context) {
context.write('{');
context.visit(node.declaration);
context.write('}');
},
DebugTag(node, context) {
context.write('{@debug ');
let started = false;

@ -2,6 +2,7 @@ import type { Binding } from '#compiler';
import type {
ArrayExpression,
ArrowFunctionExpression,
FunctionDeclaration,
VariableDeclaration,
VariableDeclarator,
Expression,
@ -160,6 +161,18 @@ export namespace AST {
};
}
/** A `{let ...}`, `{const ...}`, `{var ...}` or `{function ...}` tag */
export interface DeclarationTag extends BaseNode {
type: 'DeclarationTag';
declaration: VariableDeclaration | FunctionDeclaration;
/** @internal */
metadata: {
expression: ExpressionMetadata;
/** If this declaration tag contains an await expression, or needs to wait on other async, this is set */
promises_id?: Identifier;
};
}
/** A `{@debug ...}` tag */
export interface DebugTag extends BaseNode {
type: 'DebugTag';
@ -622,6 +635,7 @@ export namespace AST {
export type Tag =
| AST.AttachTag
| AST.ConstTag
| AST.DeclarationTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag

@ -0,0 +1,5 @@
{#if true}
{let }
{const x = }
{function f}
{/if}

@ -0,0 +1,146 @@
{
"css": null,
"js": [],
"start": 0,
"end": 52,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "IfBlock",
"elseif": false,
"start": 0,
"end": 52,
"test": {
"type": "Literal",
"start": 5,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 5
},
"end": {
"line": 1,
"column": 9
}
},
"value": true,
"raw": "true"
},
"consequent": {
"type": "Fragment",
"nodes": [
{
"type": "Text",
"start": 10,
"end": 12,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 12,
"end": 18,
"declaration": {
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "",
"start": 17,
"end": 17
},
"init": null,
"start": 17,
"end": 17
}
],
"start": 13,
"end": 17
}
},
{
"type": "Text",
"start": 18,
"end": 20,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 20,
"end": 32,
"declaration": {
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "",
"start": 31,
"end": 31
},
"init": null,
"start": 31,
"end": 31
}
],
"start": 21,
"end": 31
}
},
{
"type": "Text",
"start": 32,
"end": 34,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 34,
"end": 46,
"declaration": {
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "",
"start": 45,
"end": 45
},
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"body": [],
"start": 45,
"end": 45
},
"start": 35,
"end": 45
}
},
{
"type": "Text",
"start": 46,
"end": 47,
"raw": "\n",
"data": "\n"
}
]
},
"alternate": null
}
]
},
"options": null,
"comments": []
}

@ -0,0 +1,9 @@
{#if visible}
{let count = 1}
{const doubled = count * 2;}
{var label = 'count'}
{function format(value) {
return `${label}: ${value}`;
}}
<p>{format(doubled)}</p>
{/if}

@ -0,0 +1,11 @@
{#if visible}
{let count = 1;}
{const doubled = count * 2;}
{var label = 'count';}
{function format(value) {
return `${label}: ${value}`;
}}
<p>{format(doubled)}</p>
{/if}

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [change] = target.querySelectorAll('button');
assert.htmlEqual(
target.innerHTML,
`
<button>change name</button>
<p>Hello name</p>
`
);
change.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>change name</button>
<p>Hello other</p>
`
);
}
});

@ -0,0 +1,11 @@
<script>
const id = 'name';
</script>
{#if id}
{let name = $state(await id)}
{let greeting = $derived(await `Hello ${name}`)}
<button onclick={() => name = 'other'}>change name</button>
<p>{greeting}</p>
{/if}

@ -0,0 +1,18 @@
<script>
let visible = $state(true);
let initial = $state(2);
</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible}
{let counter = $state({ value: initial })}
{let doubled = $derived(counter.value * 2)}
{var suffix = ' total'}
{function format(value) {
return `${value}${suffix}`;
}}
<button onclick={() => (counter.value += 1)}>{counter.value}</button>
<p>{format(doubled)}</p>
{/if}

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
props: {
items: [1, 3]
}
});

@ -0,0 +1,11 @@
<script>
export let items;
</script>
{#each items as item}
{const doubled = item * 2}
{function label(value) {
return `value: ${value}`;
}}
<p>{label(doubled)}</p>
{/each}

@ -0,0 +1,14 @@
[
{
"code": "declaration_tag_invalid_type",
"message": "Declaration tags must be `let`, `const`, `var` or `function` declarations",
"start": {
"line": 2,
"column": 2
},
"end": {
"line": 2,
"column": 7
}
}
]

@ -0,0 +1,14 @@
[
{
"code": "declaration_tag_invalid_placement",
"message": "Declaration tags must be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, `<svelte:fragment>`, `<svelte:boundary>` or `<Component>`",
"start": {
"line": 5,
"column": 0
},
"end": {
"line": 5,
"column": 15
}
}
]

@ -0,0 +1,5 @@
<script>
export let a;
</script>
{let b = a + 1}

@ -852,7 +852,7 @@ declare module 'svelte/attachments' {
declare module 'svelte/compiler' {
import type { SourceMap } from 'magic-string';
import type { ArrayExpression, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression, SourceLocation } from 'estree';
import type { ArrayExpression, ArrowFunctionExpression, FunctionDeclaration, VariableDeclaration, VariableDeclarator, Expression, Identifier, MemberExpression, Node, ObjectExpression, Pattern, Program, ChainExpression, SimpleCallExpression, SequenceExpression, SourceLocation } from 'estree';
import type { Location } from 'locate-character';
import type { default as ts } from 'esrap/languages/ts';
/**
@ -1303,6 +1303,12 @@ declare module 'svelte/compiler' {
};
}
/** A `{let ...}`, `{const ...}`, `{var ...}` or `{function ...}` tag */
export interface DeclarationTag extends BaseNode {
type: 'DeclarationTag';
declaration: VariableDeclaration | FunctionDeclaration;
}
/** A `{@debug ...}` tag */
export interface DebugTag extends BaseNode {
type: 'DebugTag';
@ -1613,6 +1619,7 @@ declare module 'svelte/compiler' {
export type Tag =
| AST.AttachTag
| AST.ConstTag
| AST.DeclarationTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag

Loading…
Cancel
Save