mirror of https://github.com/sveltejs/svelte
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
parent
656dae6780
commit
59d3a36f82
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': minor
|
||||
---
|
||||
|
||||
feat: allow declarations in the template
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{#if true}
|
||||
{let }
|
||||
{const x = }
|
||||
{/if}
|
||||
@ -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,3 @@
|
||||
{#if true}
|
||||
{function foo() {}}
|
||||
{/if}
|
||||
@ -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,3 @@
|
||||
{#if true}
|
||||
{var foo = 1}
|
||||
{/if}
|
||||
@ -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 @@
|
||||
[]
|
||||
@ -0,0 +1,6 @@
|
||||
<script>
|
||||
let name = 'world';
|
||||
</script>
|
||||
|
||||
Usage when no explicit runes/legacy mode should be ok
|
||||
{const foo = world}
|
||||
Loading…
Reference in new issue