feat: allow declarations in the template (#18282)

Allows `{let/const ...}` declarations in all places (and more) where we
already allow `{@const ...}` (which will eventually get deprecated in
favor of this new feature).

Closes: #16490


Companion PRs:
- https://github.com/sveltejs/language-tools/pull/3033
- https://github.com/sveltejs/prettier-plugin-svelte/pull/533
- https://github.com/sveltejs/eslint-plugin-svelte/pull/1533
- https://github.com/sveltejs/svelte-eslint-parser/pull/891

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
fix-orphan-elements-async
Simon H 3 days ago committed by GitHub
parent 656dae6780
commit 59d3a36f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -2,6 +2,8 @@
title: {@const ...}
---
> [!NOTE] `{@const x = y}` is legacy syntax — use [`{const x = $derived(y)}`](declaration-tags) instead
The `{@const ...}` tag defines a local constant.
```svelte

@ -0,0 +1,70 @@
---
title: {let/const ...}
---
Declaration tags define local variables inside markup with `const` or `let`:
<!-- codeblock:start {"title":"Declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let boxes = [{ width: 10, height: 10 }, { width: 15, height: 15 }];
</script>
{#each boxes as box}
{const area = box.width * box.height}
{const label = `${box.width} ⨉ ${box.height} = ${area}`}
<p>{label}</p>
{/each}
```
<!-- codeblock:end -->
> [!NOTE] The [`{@const ...}`](@const) syntax is considered legacy — use declaration tags instead.
When values should be reactive, you can use `$state` and `$derived`:
<!-- codeblock:start {"title":"Reactive declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let user = $state({ name: 'Svelte' });
let editing = $state(false);
</script>
<p>Hello {user.name}</p>
<button onclick={() => editing = true}>edit name</button>
{#if editing}
{let name = $state(user.name)}
{const greeting = $derived(`Hello ${name}`)}
<hr>
<input bind:value={name} />
<p>{greeting}</p>
<button onclick={() => {
user.name = name;
editing = false;
}}>save</button>
{/if}
```
<!-- codeblock:end -->
Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the `<script>` tag or in `{#each ...}` blocks) and are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
<!-- codeblock:start {"title":"Declaration tag scope"} -->
```svelte
<!--- file: App.svelte --->
{const hello = 'hello'}
{hello} <!-- 'hello' -->
<div>
{const hello = 'hi'}
{hello} <!-- 'hi' -->
<div>
{hello} <!-- 'hi' -->
</div>
</div>
{hello} <!-- 'hello' -->
```
<!-- codeblock:end -->

@ -399,6 +399,18 @@ Invalid selector
Cannot declare a variable with the same name as an import from `<script module>`
```
### declaration_tag_invalid_type
```
Declaration tags must be `let` or `const` declarations
```
### declaration_tag_no_legacy_mode
```
Declaration tags cannot be used in legacy mode
```
### derived_invalid_export
```

@ -191,6 +191,14 @@ The same applies to components:
> {@debug ...} arguments must be identifiers, not arbitrary expressions
## declaration_tag_invalid_type
> Declaration tags must be `let` or `const` declarations
## declaration_tag_no_legacy_mode
> Declaration tags cannot be used in legacy mode
## 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 `let` or `const` 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\` or \`const\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`);
}
/**
* Declaration tags cannot be used in legacy mode
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_no_legacy_mode(node) {
e(node, 'declaration_tag_no_legacy_mode', `Declaration tags cannot be used in legacy mode\nhttps://svelte.dev/e/declaration_tag_no_legacy_mode`);
}
/**
* 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,48 @@ 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;
let end = find_matching_bracket(source, index, '{');
if (end === undefined) e.unexpected_eof(source.length);
while (source[end - 1] === ';') {
end -= 1;
}
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+\)$/;
/**

@ -302,7 +302,10 @@ export class Parser {
}
pop() {
this.fragments.pop();
const fragment = this.fragments.pop();
if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) {
fragment.metadata.transparent = false;
}
return this.stack.pop();
}

@ -1,16 +1,18 @@
/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern } from 'estree' */
/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
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)\b/y;
const regex_unsupported_declaration = /(?:var|function|class|type|interface|enum)\b/y;
const pointy_bois = { '<': '>' };
@ -31,6 +33,20 @@ export default function tag(parser) {
}
}
const declaration = read_declaration(parser);
if (declaration) {
parser.append({
type: 'DeclarationTag',
start,
end: parser.index,
declaration: /** @type {VariableDeclaration} */ (declaration),
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
const expression = read_expression(parser);
parser.allow_whitespace();
@ -47,6 +63,76 @@ export default function tag(parser) {
});
}
/**
* @param {Parser} parser
* @returns {null | import('estree').VariableDeclaration}
*/
function read_declaration(parser) {
const start = parser.index;
const unsupported = parser.match_regex(regex_unsupported_declaration);
if (unsupported) {
e.declaration_tag_invalid_type({ start, end: start + unsupported.length });
}
if (!parser.match_regex(regex_supported_declaration)) {
return null;
}
/** @type {import('estree').Statement | import('estree').VariableDeclaration} */
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;
const kind = parser.template.startsWith('const', start) ? 'const' : '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') {
e.declaration_tag_invalid_type({
start: declaration.start ?? start,
end: declaration.end ?? parser.index
});
}
// TODO support using
if (declaration.kind !== 'let' && declaration.kind !== 'const') {
e.declaration_tag_invalid_type(declaration);
}
parser.index = /** @type {number} */ (declaration.end);
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,
@ -312,6 +314,7 @@ export function analyze_module(source, options) {
options: /** @type {ValidatedCompileOptions} */ (options),
fragment: null,
parent_element: null,
in_declaration_tag: false,
reactive_statement: null,
derived_function_depth: -1
},
@ -718,6 +721,7 @@ export function analyze_component(root, source, options) {
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
fragment: ast === template.ast ? ast : null,
parent_element: null,
in_declaration_tag: false,
has_props_rune: false,
component_slots: new Set(),
expression: null,
@ -785,6 +789,7 @@ export function analyze_component(root, source, options) {
options,
fragment: ast === template.ast ? ast : null,
parent_element: null,
in_declaration_tag: false,
has_props_rune: false,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
reactive_statement: null,

@ -16,6 +16,8 @@ export interface AnalysisState {
* Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between.
*/
parent_element: string | null;
/** True if inside DeclarationTag */
in_declaration_tag: boolean;
has_props_rune: boolean;
/** Which slots the current parent component has */
component_slots: Set<string>;
@ -35,7 +37,7 @@ export interface AnalysisState {
*/
derived_function_depth: number;
/** Collected info about async `{@const }` declarations */
/** Collected info about async `{@const }`/`{let/const ...}` declarations */
async_consts?: {
id: Identifier;
/** How many `$.run(...)` entries are already allocated in this scope */

@ -255,6 +255,9 @@ export function CallExpression(node, context) {
if (expression.has_await) {
context.state.analysis.async_deriveds.add(node);
}
// Tell surrounding declaration tag about metadata for correct calculation of blockers etc
if (context.state.in_declaration_tag) context.state.expression?.merge(expression);
} else if (rune === '$inspect') {
context.next({ ...context.state, function_depth: context.state.function_depth + 1 });
} else {

@ -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 { mark_async_declaration } from './DeclarationTag.js';
/**
* @param {AST.ConstTag} node
@ -44,28 +44,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,58 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
import * as e from '../../../errors.js';
import { validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.DeclarationTag} node
* @param {Context} context
*/
export function DeclarationTag(node, context) {
validate_opening_tag(node, context.state, node.declaration.kind[0]);
if (!context.state.analysis.runes && !context.state.analysis.maybe_runes) {
e.declaration_tag_no_legacy_mode(node);
}
context.visit(node.declaration, {
...context.state,
in_declaration_tag: true,
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/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];

@ -4,7 +4,7 @@
/** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */
import { walk } from 'zimmerframe';
import * as b from '#compiler/builders';
import { build_getter, is_state_source } from './utils.js';
import { build_getter, get_transform } from './utils.js';
import { render_stylesheet } from '../css/index.js';
import { dev, filename } from '../../../state.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
@ -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';
@ -66,20 +67,7 @@ const visitors = {
const scope = state.scopes.get(node);
if (scope && scope !== state.scope) {
const transform = { ...state.transform };
for (const [name, binding] of scope.declarations) {
if (
binding.kind === 'normal' ||
// Reads of `$state(...)` declarations are not
// transformed if they are never reassigned
(binding.kind === 'state' && !is_state_source(binding, state.analysis))
) {
delete transform[name];
}
}
next({ ...state, transform, scope });
next({ ...state, transform: get_transform(scope, state), scope });
} else {
next();
}
@ -99,6 +87,7 @@ const visitors = {
Comment,
Component,
ConstTag,
DeclarationTag,
DebugTag,
EachBlock,
ExportNamedDeclaration,

@ -179,3 +179,24 @@ export function create_derived(state, expression, async = false) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
}
}
/**
* @param {Scope} scope
* @param {ClientTransformState} state
*/
export function get_transform(scope, state) {
const transform = { ...state.transform };
for (const [name, binding] of scope.declarations) {
if (
binding.kind === 'normal' ||
// Reads of `$state(...)` declarations are not
// transformed if they are never reassigned
(binding.kind === 'state' && !is_state_source(binding, state.analysis))
) {
delete transform[name];
}
}
return transform;
}

@ -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,87 @@
/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, 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 {ExpressionStatement[]} 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));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
}

@ -18,7 +18,7 @@ import {
is_customizable_select_element
} from '../../../nodes.js';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_getter } from '../utils.js';
import { build_getter, get_transform } from '../utils.js';
import {
get_attribute_name,
build_attribute_value,
@ -300,11 +300,14 @@ export function RegularElement(node, context) {
}
}
const scope = /** @type {Scope} */ (context.state.scopes.get(node.fragment));
/** @type {ComponentClientTransformState} */
const state = {
...context.state,
metadata,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
scope,
transform: get_transform(scope, context.state),
preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea'
};
@ -318,8 +321,19 @@ export function RegularElement(node, context) {
state.options.preserveComments
);
const has_declarations = !node.fragment.metadata.transparent;
/** @type {typeof state} */
const child_state = { ...state, init: [], update: [], after_update: [], snippets: [] };
const child_state = {
...state,
init: [],
update: [],
after_update: [],
snippets: [],
consts: has_declarations ? [] : state.consts,
async_consts: has_declarations ? undefined : state.async_consts,
memoizer: has_declarations ? new Memoizer() : state.memoizer
};
for (const node of hoisted) {
context.visit(node, child_state);
@ -427,11 +441,21 @@ export function RegularElement(node, context) {
}
}
if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock')) {
if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock') || has_declarations) {
if (child_state.async_consts && child_state.async_consts.thunks.length > 0) {
child_state.consts.push(
b.var(
child_state.async_consts.id,
b.call('$.run', b.array(child_state.async_consts.thunks))
)
);
}
// Wrap children in `{...}` to avoid declaration conflicts
context.state.init.push(
b.block([
...child_state.snippets,
...child_state.consts,
...child_state.init,
...element_state.init,
child_state.update.length > 0 ? build_render_statement(child_state) : b.empty,

@ -40,6 +40,7 @@ export function SvelteBoundary(node, context) {
const hoisted = [];
let has_const = false;
let has_declaration = false;
// const tags need to live inside the boundary, but might also be referenced in hoisted snippets.
// to resolve this we cheat: we duplicate const tags inside snippets
@ -55,6 +56,10 @@ export function SvelteBoundary(node, context) {
});
}
}
if (child.type === 'DeclarationTag') {
has_declaration = true;
}
}
for (const child of node.fragment.nodes) {
@ -68,10 +73,10 @@ export function SvelteBoundary(node, context) {
if (child.type === 'SnippetBlock') {
if (
context.state.options.experimental.async &&
has_const &&
(has_const || has_declaration) &&
!['failed', 'pending'].includes(child.expression.name)
) {
// we can't hoist snippets as they may reference const tags, so we just keep them in the fragment
// we can't hoist snippets as they may reference const/declaration tags, so we just keep them in the fragment
nodes.push(child);
} else {
/** @type {Statement[]} */

@ -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 ...}` 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,85 @@
/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, 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 {ExpressionStatement[]} 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));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
}

@ -17,17 +17,23 @@ import { is_customizable_select_element } from '../../../nodes.js';
export function RegularElement(node, context) {
const name = context.state.namespace === 'html' ? node.name.toLowerCase() : node.name;
const namespace = determine_namespace_for_children(node, context.state.namespace);
const has_child_declarations = !node.fragment.metadata.transparent;
/** @type {ComponentServerTransformState} */
const state = {
...context.state,
namespace,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
preserve_whitespace:
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea',
init: [],
template: []
template: [],
async_consts: undefined
};
/** @type {ComponentServerTransformState} */
const attribute_state = { ...state, scope: context.state.scope };
const node_is_void = is_void(name);
const optimiser = new PromiseOptimiser();
@ -50,7 +56,11 @@ export function RegularElement(node, context) {
if (!is_special) {
// only open the tag in the non-special path
state.template.push(b.literal(`<${name}`));
body = build_element_attributes(node, { ...context, state }, optimiser.transform);
body = build_element_attributes(
node,
{ ...context, state: attribute_state },
optimiser.transform
);
state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance
}
@ -72,10 +82,7 @@ export function RegularElement(node, context) {
node.fragment.nodes,
context.path,
namespace,
{
...state,
scope: /** @type {Scope} */ (state.scopes.get(node.fragment))
},
state,
state.preserve_whitespace,
state.options.preserveComments
);
@ -205,7 +212,17 @@ export function RegularElement(node, context) {
state.template.push(b.stmt(b.call('$.pop_element')));
}
if (optimiser.is_async()) {
if (has_child_declarations && state.async_consts && state.async_consts.thunks.length > 0) {
state.init.push(
b.var(state.async_consts.id, b.call('$$renderer.run', b.array(state.async_consts.thunks)))
);
}
if (has_child_declarations) {
context.state.template.push(
...optimiser.render([b.block([...state.init, ...build_template(state.template)])])
);
} else if (optimiser.is_async()) {
context.state.template.push(
...optimiser.render([...state.init, ...build_template(state.template)])
);

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

@ -217,6 +217,7 @@ function* find_descendants(fragment) {
case 'SnippetBlock':
case 'DebugTag':
case 'ConstTag':
case 'DeclarationTag':
case 'Comment':
case 'ExpressionTag':
break;

@ -603,6 +603,48 @@ const svelte_visitors = (comments) => ({
context.write('}');
},
DeclarationTag(node, context) {
context.write('{');
// This is duplicated from esrap's handling of VariableDeclaration,
// which we need to do in order to omit the trailing semicolon that esrap would add.
const open = context.new();
const join = context.new();
const child_context = context.new();
context.append(child_context);
child_context.write(`${node.declaration.kind} `);
child_context.append(open);
const declarations = node.declaration.declarations;
let first = true;
for (const d of declarations) {
if (!first) child_context.append(join);
first = false;
child_context.visit(d);
}
const length = child_context.measure() + 2 * (declarations.length - 1);
const multiline = child_context.multiline || (declarations.length > 1 && length > 50);
if (multiline) {
context.multiline = true;
if (declarations.length > 1) open.indent();
join.write(',');
join.newline();
if (declarations.length > 1) context.dedent();
} else {
join.write(', ');
}
context.write('}');
},
DebugTag(node, context) {
context.write('{@debug ');
let started = false;

@ -160,6 +160,18 @@ export namespace AST {
};
}
/** A `{let ...}` or `{const ...}` tag */
export interface DeclarationTag extends BaseNode {
type: 'DeclarationTag';
declaration: VariableDeclaration;
/** @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 +634,7 @@ export namespace AST {
export type Tag =
| AST.AttachTag
| AST.ConstTag
| AST.DeclarationTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag

@ -0,0 +1,113 @@
{
"css": null,
"js": [],
"start": 0,
"end": 38,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "IfBlock",
"elseif": false,
"start": 0,
"end": 38,
"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": 33,
"raw": "\n",
"data": "\n"
}
]
},
"alternate": null
}
]
},
"options": null
}

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

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

@ -0,0 +1,13 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['async-server', 'client', 'hydrate'],
ssrHtml: `<h1>Hello, world!</h1> 5 01234 5 sync 6 5 0 10`,
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, `<h1>Hello, world!</h1> 5 01234 5 sync 6 5 0 10`);
}
});

@ -0,0 +1,31 @@
<script>
let name = $state('world');
</script>
<svelte:boundary>
{const sync = 'sync'}
{const number = await Promise.resolve(5)}
{const after_async =number + 1}
{const { length, 0: first } = await '01234'}
{#snippet greet()}
{const greeting = $derived(await `Hello, ${name}!`)}
<h1>{greeting}</h1>
{number}
{#if number > 4 && after_async && greeting}
{const length = $derived(await number)}
{#each { length }, index}
{const i = $derived(await index)}
{i}
{/each}
{/if}
{/snippet}
{@render greet()}
{number} {sync} {after_async} {length} {first}
{#if sync}
{const double = $derived(number * 2)}
{double}
{/if}
</svelte:boundary>

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

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

@ -0,0 +1,13 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: '<button>0 | 0</button>',
async test({ assert, target }) {
const [increment] = target.querySelectorAll('button');
increment.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>1 | 2</button>');
}
});

@ -0,0 +1,3 @@
{let count = $state(0)}
{let doubled = $derived(count * 2)}
<button onclick={() => (count += 1)}>{count} | {doubled}</button>

@ -0,0 +1,37 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>top 2</button><button>toggle</button><button>2</button><p>4 total</p><div><span>nested</span></div><div><span>nested</span></div>`,
async test({ assert, target }) {
const [top, toggle, increment] = target.querySelectorAll('button');
top.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>top 4</button><button>toggle</button><button>2</button><p>4 total</p><div><span>nested</span></div><div><span>nested</span></div>`
);
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>top 4</button><button>toggle</button><button>3</button><p>6 total</p><div><span>nested</span></div><div><span>nested</span></div>`
);
toggle.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>top 4</button><button>toggle</button><div><span>nested</span></div>`
);
toggle.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>top 4</button><button>toggle</button><button>2</button><p>4 total</p><div><span>nested</span></div><div><span>nested</span></div>`
);
}
});

@ -0,0 +1,29 @@
<script>
let visible = $state(true);
let initial = $state(2);
</script>
{let top = $state(1)}
{let top_doubled = $derived(top * 2)}
<button onclick={() => (top += 1)}>top {top_doubled}</button>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible}
{let counter = $state({ value: initial })}
{let doubled = $derived(counter.value * 2)}
{const suffix = ' total'}
{const format = (value) => `${value}${suffix}`}
<button onclick={() => (counter.value += 1)}>{counter.value}</button>
<p>{format(doubled)}</p>
<div>
{const doubled = 'nested'}
<span>{doubled}</span>
</div>
{/if}
<div>
{const nested = 'nested'}
<span>{nested}</span>
</div>

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

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

@ -0,0 +1,14 @@
[
{
"code": "declaration_tag_no_legacy_mode",
"message": "Declaration tags cannot be used in legacy mode",
"start": {
"line": 5,
"column": 0
},
"end": {
"line": 5,
"column": 19
}
}
]

@ -0,0 +1,5 @@
<script>
export let name = 'world';
</script>
{const foo = 'foo'}

@ -0,0 +1,6 @@
<script>
let name = 'world';
</script>
Usage when no explicit runes/legacy mode should be ok
{const foo = world}

@ -1303,6 +1303,12 @@ declare module 'svelte/compiler' {
};
}
/** A `{let ...}` or `{const ...}` tag */
export interface DeclarationTag extends BaseNode {
type: 'DeclarationTag';
declaration: VariableDeclaration;
}
/** 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