From 59d3a36f825d9f2ca29dbdbec0ad27e4f5bf1105 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 29 May 2026 14:05:13 +0200 Subject: [PATCH] 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 --- .changeset/four-loops-agree.md | 5 + .../docs/03-template-syntax/10-@const.md | 2 + .../03-template-syntax/11-declaration-tags.md | 70 +++++++++++ .../98-reference/.generated/compile-errors.md | 12 ++ .../messages/compile-errors/template.md | 8 ++ packages/svelte/src/compiler/errors.js | 18 +++ packages/svelte/src/compiler/legacy.js | 4 + .../src/compiler/phases/1-parse/acorn.js | 45 ++++++- .../src/compiler/phases/1-parse/index.js | 5 +- .../src/compiler/phases/1-parse/state/tag.js | 92 +++++++++++++- .../src/compiler/phases/2-analyze/index.js | 5 + .../src/compiler/phases/2-analyze/types.d.ts | 4 +- .../2-analyze/visitors/CallExpression.js | 3 + .../phases/2-analyze/visitors/ConstTag.js | 27 +---- .../2-analyze/visitors/DeclarationTag.js | 58 +++++++++ .../phases/2-analyze/visitors/Identifier.js | 2 +- .../3-transform/client/transform-client.js | 19 +-- .../phases/3-transform/client/utils.js | 21 ++++ .../3-transform/client/visitors/ConstTag.js | 37 ++---- .../client/visitors/DeclarationTag.js | 87 ++++++++++++++ .../client/visitors/RegularElement.js | 32 ++++- .../client/visitors/SvelteBoundary.js | 9 +- .../3-transform/server/transform-server.js | 2 + .../phases/3-transform/server/types.d.ts | 2 +- .../3-transform/server/visitors/ConstTag.js | 33 ++--- .../server/visitors/DeclarationTag.js | 85 +++++++++++++ .../server/visitors/RegularElement.js | 31 +++-- .../src/compiler/phases/3-transform/utils.js | 1 + packages/svelte/src/compiler/phases/nodes.js | 1 + packages/svelte/src/compiler/print/index.js | 42 +++++++ .../svelte/src/compiler/types/template.d.ts | 13 ++ .../loose-declaration-tag/input.svelte | 4 + .../samples/loose-declaration-tag/output.json | 113 ++++++++++++++++++ .../samples/declaration-tag/input.svelte | 7 ++ .../samples/declaration-tag/output.svelte | 7 ++ .../async-declaration-tag-2/_config.js | 13 ++ .../async-declaration-tag-2/main.svelte | 31 +++++ .../samples/async-declaration-tag/_config.js | 43 +++++++ .../samples/async-declaration-tag/main.svelte | 20 ++++ .../declaration-tags-no-script/_config.js | 13 ++ .../declaration-tags-no-script/main.svelte | 3 + .../samples/declaration-tags/_config.js | 37 ++++++ .../samples/declaration-tags/main.svelte | 29 +++++ .../errors.json | 14 +++ .../input.svelte | 3 + .../declaration-tag-invalid-type/errors.json | 14 +++ .../declaration-tag-invalid-type/input.svelte | 3 + .../declaration-tag-legacy-mode/errors.json | 14 +++ .../declaration-tag-legacy-mode/input.svelte | 5 + .../declaration-tag-maybe-runes/errors.json | 1 + .../declaration-tag-maybe-runes/input.svelte | 6 + packages/svelte/types/index.d.ts | 7 ++ 52 files changed, 1053 insertions(+), 109 deletions(-) create mode 100644 .changeset/four-loops-agree.md create mode 100644 documentation/docs/03-template-syntax/11-declaration-tags.md create mode 100644 packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js create mode 100644 packages/svelte/tests/parser-modern/samples/loose-declaration-tag/input.svelte create mode 100644 packages/svelte/tests/parser-modern/samples/loose-declaration-tag/output.json create mode 100644 packages/svelte/tests/print/samples/declaration-tag/input.svelte create mode 100644 packages/svelte/tests/print/samples/declaration-tag/output.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/declaration-tags/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/declaration-tags/main.svelte create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-invalid-function/errors.json create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-invalid-function/input.svelte create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-invalid-type/errors.json create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-invalid-type/input.svelte create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/errors.json create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/input.svelte create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/errors.json create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/input.svelte diff --git a/.changeset/four-loops-agree.md b/.changeset/four-loops-agree.md new file mode 100644 index 0000000000..46d30e8464 --- /dev/null +++ b/.changeset/four-loops-agree.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow declarations in the template diff --git a/documentation/docs/03-template-syntax/10-@const.md b/documentation/docs/03-template-syntax/10-@const.md index 2a587b7a3d..6f2edc1a37 100644 --- a/documentation/docs/03-template-syntax/10-@const.md +++ b/documentation/docs/03-template-syntax/10-@const.md @@ -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 diff --git a/documentation/docs/03-template-syntax/11-declaration-tags.md b/documentation/docs/03-template-syntax/11-declaration-tags.md new file mode 100644 index 0000000000..34b7164775 --- /dev/null +++ b/documentation/docs/03-template-syntax/11-declaration-tags.md @@ -0,0 +1,70 @@ +--- +title: {let/const ...} +--- + +Declaration tags define local variables inside markup with `const` or `let`: + + +```svelte + + + +{#each boxes as box} + {const area = box.width * box.height} + {const label = `${box.width} ⨉ ${box.height} = ${area}`} + +

{label}

+{/each} +``` + + +> [!NOTE] The [`{@const ...}`](@const) syntax is considered legacy — use declaration tags instead. + +When values should be reactive, you can use `$state` and `$derived`: + + +```svelte + + + +

Hello {user.name}

+ + +{#if editing} + {let name = $state(user.name)} + {const greeting = $derived(`Hello ${name}`)} + +
+ +

{greeting}

+ + +{/if} +``` + + +Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the ` + + + {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}!`)} +

{greeting}

+ {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} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js new file mode 100644 index 0000000000..8fd2fc0976 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js @@ -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, + ` + + +

Hello name

+
nested Hi name
+ ` + ); + + top.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

Hello name

+
nested Hi name
+ ` + ); + + change.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

Hello other

+
nested Hi other
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte new file mode 100644 index 0000000000..4521ea2e41 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte @@ -0,0 +1,20 @@ + + +{let name = $state(top_id)} + + +{#if id} + {let name = $state(await id)} + {let greeting = $derived(await `Hello ${name}`)} + + +

{greeting}

+
+ {const nested = 'nested'} + {const greeting2 = $derived(await `Hi ${name}`)} + {nested} {greeting2} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/_config.js b/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/_config.js new file mode 100644 index 0000000000..901df42df7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/_config.js @@ -0,0 +1,13 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '', + async test({ assert, target }) { + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/main.svelte b/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/main.svelte new file mode 100644 index 0000000000..da72bc69c6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/declaration-tags-no-script/main.svelte @@ -0,0 +1,3 @@ +{let count = $state(0)} +{let doubled = $derived(count * 2)} + diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tags/_config.js b/packages/svelte/tests/runtime-runes/samples/declaration-tags/_config.js new file mode 100644 index 0000000000..54e390ec83 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/declaration-tags/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

4 total

nested
nested
`, + async test({ assert, target }) { + const [top, toggle, increment] = target.querySelectorAll('button'); + + top.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

4 total

nested
nested
` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

6 total

nested
nested
` + ); + + toggle.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `
nested
` + ); + + toggle.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

4 total

nested
nested
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tags/main.svelte b/packages/svelte/tests/runtime-runes/samples/declaration-tags/main.svelte new file mode 100644 index 0000000000..5953104092 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/declaration-tags/main.svelte @@ -0,0 +1,29 @@ + + +{let top = $state(1)} +{let top_doubled = $derived(top * 2)} + + + + +{#if visible} + {let counter = $state({ value: initial })} + {let doubled = $derived(counter.value * 2)} + {const suffix = ' total'} + {const format = (value) => `${value}${suffix}`} + + +

{format(doubled)}

+
+ {const doubled = 'nested'} + {doubled} +
+{/if} + +
+ {const nested = 'nested'} + {nested} +
diff --git a/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/errors.json new file mode 100644 index 0000000000..bf0266225f --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/errors.json @@ -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 + } + } +] diff --git a/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/input.svelte new file mode 100644 index 0000000000..8cfdf59c0f --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/input.svelte @@ -0,0 +1,3 @@ +{#if true} + {function foo() {}} +{/if} diff --git a/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/errors.json new file mode 100644 index 0000000000..2a9b3c0140 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/errors.json @@ -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 + } + } +] diff --git a/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/input.svelte new file mode 100644 index 0000000000..eb9fcd5e75 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type/input.svelte @@ -0,0 +1,3 @@ +{#if true} + {var foo = 1} +{/if} diff --git a/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/errors.json new file mode 100644 index 0000000000..6b89f2eab8 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/errors.json @@ -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 + } + } +] diff --git a/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/input.svelte new file mode 100644 index 0000000000..026d4eff1e --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-legacy-mode/input.svelte @@ -0,0 +1,5 @@ + + +{const foo = 'foo'} diff --git a/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/errors.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/errors.json @@ -0,0 +1 @@ +[] diff --git a/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/input.svelte new file mode 100644 index 0000000000..081f242a81 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-maybe-runes/input.svelte @@ -0,0 +1,6 @@ + + +Usage when no explicit runes/legacy mode should be ok +{const foo = world} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 3f71d44177..1c3795be80 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -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