diff --git a/.changeset/create-element-fast-path.md b/.changeset/create-element-fast-path.md
deleted file mode 100644
index 8b88a14163..0000000000
--- a/.changeset/create-element-fast-path.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: use `createElement` instead of `createElementNS` for HTML elements
diff --git a/.changeset/current-sources-set.md b/.changeset/current-sources-set.md
deleted file mode 100644
index e1a4dc43b2..0000000000
--- a/.changeset/current-sources-set.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-perf: store `current_sources` as a `Set` for O(1) membership checks
diff --git a/.changeset/declaration-tag-multiple-declarators.md b/.changeset/declaration-tag-multiple-declarators.md
new file mode 100644
index 0000000000..3f17162b54
--- /dev/null
+++ b/.changeset/declaration-tag-multiple-declarators.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correctly transform references to earlier declarators in a declaration tag (e.g. `{let a = $state(0), b = $derived(a * 2)}`)
diff --git a/.changeset/declaration-tag-state-referenced-locally.md b/.changeset/declaration-tag-state-referenced-locally.md
new file mode 100644
index 0000000000..00803c3e1c
--- /dev/null
+++ b/.changeset/declaration-tag-state-referenced-locally.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: avoid spurious `state_referenced_locally` warnings for `$derived` declarations in declaration tags
diff --git a/.changeset/declaration-tag-whitespace.md b/.changeset/declaration-tag-whitespace.md
new file mode 100644
index 0000000000..b64d1dd5a1
--- /dev/null
+++ b/.changeset/declaration-tag-whitespace.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: tolerate whitespace before `let`/`const` in declaration tags
diff --git a/.changeset/fix-find-matching-bracket-infinite-loop.md b/.changeset/fix-find-matching-bracket-infinite-loop.md
new file mode 100644
index 0000000000..c0656ea794
--- /dev/null
+++ b/.changeset/fix-find-matching-bracket-infinite-loop.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: prevent infinite loop when a tag's expression ends with a trailing `/` at the end of the input
diff --git a/.changeset/fix-type-identifier-in-tag.md b/.changeset/fix-type-identifier-in-tag.md
new file mode 100644
index 0000000000..90e03da80f
--- /dev/null
+++ b/.changeset/fix-type-identifier-in-tag.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: more robust parsing of declaration tags with regards to `type`
diff --git a/.changeset/input-type-before-value.md b/.changeset/input-type-before-value.md
new file mode 100644
index 0000000000..68d02f920f
--- /dev/null
+++ b/.changeset/input-type-before-value.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: preserve newlines in spread input values when the `type` attribute is applied after `value`
diff --git a/.changeset/search-params-duplicate-set.md b/.changeset/search-params-duplicate-set.md
new file mode 100644
index 0000000000..9fcaa8b289
--- /dev/null
+++ b/.changeset/search-params-duplicate-set.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: update `SvelteURLSearchParams` when setting duplicate keys to the same joined value
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f72b6c23e..7eff1b9fe7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,7 +80,7 @@ jobs:
Lint:
permissions: {}
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
@@ -99,7 +99,7 @@ jobs:
if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail
run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); }
- name: check browser-support docs page is up to date
- run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/05-browser-support.md`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/05-browser-support.md; exit 1); }'
+ run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }'
Benchmarks:
permissions: {}
runs-on: ubuntu-latest
diff --git a/.prettierignore b/.prettierignore
index 92d9bc797b..28f447f359 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -8,6 +8,7 @@ packages/**/config/*.js
# packages/svelte
packages/svelte/messages/**/*.md
packages/svelte/scripts/_bundle.js
+packages/svelte/scripts/_baseline/*.ts
packages/svelte/src/compiler/errors.js
packages/svelte/src/compiler/warnings.js
packages/svelte/src/internal/client/errors.js
diff --git a/AGENTS.md b/AGENTS.md
index c6cd3ea310..7f143248aa 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,6 +4,8 @@ This guide is for AI coding agents working in the Svelte monorepo.
**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here.
+When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite.
+
## Quick Reference
If asked to do a performance investigation, use the `performance-investigation` skill.
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..e0edaf6a38
--- /dev/null
+++ b/documentation/docs/03-template-syntax/11-declaration-tags.md
@@ -0,0 +1,72 @@
+---
+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] Declaration tags are available since Svelte 5.56.
+
+> [!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}
+ editing = true}>edit name
+
+{#if editing}
+ {let name = $state(user.name)}
+ {const greeting = $derived(`Hello ${name}`)}
+
+
+
+ {greeting}
+
+ {
+ user.name = name;
+ editing = false;
+ }}>save
+{/if}
+```
+
+
+Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the ` `;
}
if (tag === 'input') {
- // `bind:checked` requires type="checkbox"|"radio"; `bind:group` too.
+ // `bind:checked` and `bind:group` require type="checkbox" | "radio"
const type =
name === 'checked' || name === 'indeterminate'
? ' type="checkbox"'
@@ -567,7 +563,6 @@ function binding_fixture(name: string, props: BindingProperty): string {
* Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then
* bundle the result through the shared `bundle` helper. Fixtures are tiny
* so circular-dep warnings from the Svelte runtime are silenced.
- *
*/
async function bundle_fixture(feature: Feature): Promise {
const entry_code =
@@ -612,6 +607,8 @@ async function find_all_conditional_features(
const features = enumerate_features(subpackage_exports);
const rows: ConditionalRow[] = [];
+ const missing_doc_links: string[] = [];
+
for (let i = 0; i < features.length; i++) {
const feature = features[i];
process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80));
@@ -645,25 +642,35 @@ async function find_all_conditional_features(
}
}
+ let doc_link = doc_links[feature.name];
+ if (doc_link === undefined) {
+ doc_link = null;
+ missing_doc_links.push(feature.name);
+ }
+
rows.push({
name: feature.name,
+ doc_link,
versions,
baseline_year: final_year
});
}
process.stdout.write('\n');
+ if (missing_doc_links.length) {
+ throw new Error(`Missing documentation url for some features.
+Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url.
+${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`);
+ }
+
return rows;
}
-/**
- * Render the per-feature browser-requirements table from the auto-detected
- * rows. Sorted by Safari floor descending, then alphabetically.
- */
-function render_conditional_table(rows: ConditionalRow[], runtime_floor: RuntimeFloor): string {
- if (rows.length === 0) {
+function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string {
+ if (features.length === 0) {
return '_No features currently require browser versions newer than the runtime floor._';
}
+ features.sort((a, b) => a.name.localeCompare(b.name));
const browsers = [
['chrome', 'Chrome/Edge'],
@@ -671,32 +678,25 @@ function render_conditional_table(rows: ConditionalRow[], runtime_floor: Runtime
['safari', 'Safari']
] as const;
- const runtime_versions = browser_versions_for(runtime_floor);
-
- const sorted = [...rows].sort((a, b) => {
- const sa = Number(a.versions.safari ?? '0');
- const sb = Number(b.versions.safari ?? '0');
- if (sb !== sa) return sb - sa;
- return a.name.localeCompare(b.name);
- });
-
- const header = '| Feature | ' + browsers.map(([, label]) => `${label}`).join(' | ') + ' |';
- const sep = '| --- |' + browsers.map(() => ' ---: |').join('');
+ const floor_versions = browser_versions_for(runtime_floor);
- const body = sorted.map((entry) => {
- const cells = browsers.map(([key]) => {
- const v = entry.versions[key];
+ const rows: string[][] = [];
+ for (const row of features) {
+ const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name;
+ const versions = browsers.map(([key]) => {
+ const v = row.versions[key];
if (v === null) return 'not supported';
if (v === undefined) return '— ';
- const floor_v = runtime_versions[key];
- return floor_v && Number(v) <= Number(floor_v)
- ? '— '
- : v;
+ const floor_v = floor_versions[key];
+ if (floor_v && Number(v) <= Number(floor_v))
+ return '— ';
+
+ return v;
});
- return `| ${entry.name} | ${cells.join(' | ')} |`;
- });
+ rows.push([name_cell, ...versions]);
+ }
- return [header, sep, ...body].join('\n');
+ return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows);
}
function browser_versions_for(target: RuntimeFloor): Record {
@@ -750,53 +750,59 @@ function browser_versions_for(target: RuntimeFloor): Record {
return lookup;
}
-function render_table(versions: Record, target: RuntimeFloor): string {
- // Chrome and Edge ship from the same engine and historically resolve to the
- // same Baseline version. Collapse them into one row when they match, but
- // fall back to listing them separately if they ever drift.
- const chrome_edge: [string, string] | null =
- versions.chrome && versions.chrome === versions.edge ? ['Chrome/Edge', versions.chrome] : null;
-
- const base_rows: Array<[string, string]> = chrome_edge
- ? [chrome_edge, ['Chrome (Android)', versions.chrome_android]]
- : [
- ['Chrome', versions.chrome],
- ['Chrome (Android)', versions.chrome_android],
- ['Edge', versions.edge]
- ];
-
- const rows = [
- ...base_rows,
- ['Firefox', versions.firefox],
- ['Firefox (Android)', versions.firefox_android],
- ['Safari', versions.safari],
- ['Safari (iOS)', versions.safari_ios],
- ['Opera', versions.opera],
- ['Opera (Android)', versions.opera_android],
- ['Samsung Internet', versions.samsunginternet_android],
- ['Android WebView', versions.webview_android]
- ].filter(([label, version]) => version !== undefined) as Array<[string, string]>;
-
- const headings = ['Browser', 'Minimum version'];
-
- const widths = headings.map((heading, i) =>
- Math.max(heading.length, ...rows.map((r) => String(r[i]).length))
- );
+const BROWSER = {
+ chrome: 'Chrome',
+ edge: 'Edge',
+ firefox: 'Firefox',
+ safari: 'Safari',
+ opera: 'Opera',
+ samsung_internet: 'Samsung Internet',
+ webview_android: 'Android WebView',
+ internet_explorer: 'Internet Explorer'
+};
- const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - s.length));
+function render_browser_table(versions: Record, target: RuntimeFloor): string {
+ const rows: Array<[string, string]> = [
+ [BROWSER.chrome, versions.chrome],
+ [`${BROWSER.chrome} (Android)`, versions.chrome_android]
+ ];
- const header = `| ${headings.map((heading, i) => pad(heading, widths[i])).join(' | ')} |`;
- const sep = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`;
- const body = rows
- .map(([a, b]) => `| ${pad(a, widths[0])} | ${pad(String(b), widths[1])} |`)
- .join('\n');
+ if (versions.chrome === versions.edge) {
+ rows[0][0] += `/${BROWSER.edge}`;
+ } else {
+ rows.push([BROWSER.edge, versions.edge]);
+ }
+
+ rows.push(
+ [BROWSER.firefox, versions.firefox],
+ [`${BROWSER.firefox} (Android)`, versions.firefox_android],
+ [BROWSER.safari, versions.safari],
+ [`${BROWSER.safari} (iOS)`, versions.safari_ios],
+ [BROWSER.opera, versions.opera],
+ [`${BROWSER.opera} (Android)`, versions.opera_android],
+ [BROWSER.samsung_internet, versions.samsunginternet_android],
+ [BROWSER.webview_android, versions.webview_android],
+ [BROWSER.internet_explorer, 'not supported']
+ );
const target_label = target === 'newly' ? '"newly available"' : target;
- return `${header}\n${sep}\n${body}\n\n> [!NOTE] This equates to a Baseline target of ${target_label}.`;
+ return (
+ render_markdown_table(
+ ['Browser', 'Minimum version'],
+ rows.filter(([, version]) => version !== undefined)
+ ) +
+ `\n\n> [!NOTE] This equates to a Baseline target of ${target_label}.`
+ );
+}
+
+function render_markdown_table(headers: string[], rows: string[][]): string {
+ return `| ${headers.join(' | ')} |
+| ${headers.map(() => '-').join(' | ')} |
+${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}
+`;
}
-/* eslint-disable no-console */
async function main() {
console.log('Preparing scratch directory…');
// Wipe and recreate so stale bundles can't leak into the next scan.
@@ -844,14 +850,11 @@ async function main() {
const versions = browser_versions_for(target);
console.log('Rewriting docs page…');
- generate('browser-support.md', render_table(versions, target));
+ generate('browser-support.md', render_browser_table(versions, target));
generate('browser-support-features.md', render_conditional_table(conditional_rows, target));
console.log('Done.');
} finally {
- // Ensure cleanup happens even on failure — otherwise leftover bundle
- // files in `scripts/_baseline/` get picked up by `pnpm lint` on the
- // next CI step and produce spurious naming/no-console errors.
fs.rmSync(tmp_dir, { recursive: true, force: true });
}
}
diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js
index bafb450ab4..3b28d5e2a1 100644
--- a/packages/svelte/src/compiler/errors.js
+++ b/packages/svelte/src/compiler/errors.js
@@ -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
diff --git a/packages/svelte/src/compiler/legacy.js b/packages/svelte/src/compiler/legacy.js
index 459858fe13..c51cd8434b 100644
--- a/packages/svelte/src/compiler/legacy.js
+++ b/packages/svelte/src/compiler/legacy.js
@@ -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 {
diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js
index 45a7c2a58c..d338f9ffb8 100644
--- a/packages/svelte/src/compiler/phases/1-parse/acorn.js
+++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js
@@ -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+\)$/;
/**
diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js
index 5242cba31f..02d1629c99 100644
--- a/packages/svelte/src/compiler/phases/1-parse/index.js
+++ b/packages/svelte/src/compiler/phases/1-parse/index.js
@@ -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();
}
diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js
index ff153128a5..15c79e0353 100644
--- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js
+++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js
@@ -1,16 +1,20 @@
-/** @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|interface|enum)\b/y;
+// `type` is a contextual keyword; this is just a shape hint, confirmed by parsing.
+const regex_maybe_type_declaration = /type\b/y;
const pointy_bois = { '<': '>' };
@@ -31,6 +35,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 +65,89 @@ 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) &&
+ // `type` is special, since it is not a reserved keyword and can be used
+ // as part of a valid expression. We gotta parse first and then see what it is.
+ !parser.match_regex(regex_maybe_type_declaration)
+ ) {
+ return null;
+ }
+
+ const initial_comment_count = parser.root.comments.length;
+
+ /** @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') {
+ if (declaration.type === 'ExpressionStatement') {
+ parser.root.comments.length = initial_comment_count; // Else they show up duplicated
+ return null;
+ } else {
+ // This is a TSTypeAliasDeclaration
+ 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;
diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js
index 63fbc68fb3..47299c9de5 100644
--- a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js
+++ b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js
@@ -39,7 +39,9 @@ function find_string_end(string, search_start_index, string_start_char) {
* @returns {number} The index of the end of this regex expression, or `Infinity` if not found.
*/
function find_regex_end(string, search_start_index) {
- return find_unescaped_char(string, search_start_index, '/');
+ const slash = find_unescaped_char(string, search_start_index, '/');
+ const eol = find_unescaped_char(string, search_start_index, '\n');
+ return slash < eol ? slash : Infinity;
}
/**
@@ -105,7 +107,11 @@ export function find_matching_bracket(template, index, open) {
continue;
case '/': {
const next_char = template[i + 1];
- if (!next_char) continue;
+ if (!next_char) {
+ // `/` is the last character; advance past it so we don't loop forever
+ i++;
+ continue;
+ }
if (next_char === '/') {
i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length;
continue;
@@ -114,7 +120,12 @@ export function find_matching_bracket(template, index, open) {
i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length;
continue;
}
- i = find_regex_end(template, i + 1) + '/'.length;
+ const end = find_regex_end(template, i + 1) + '/'.length;
+ if (end === Infinity) {
+ i++;
+ } else {
+ i = end;
+ }
continue;
}
default: {
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index f4719251bd..d6eef085d1 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -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
},
@@ -725,6 +728,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,
@@ -792,6 +796,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,
diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
index 354f1c0856..bd486a12ca 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
+++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts
@@ -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;
@@ -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 */
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
index 52eba8c735..d3524af4db 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
@@ -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 {
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
index 4f07249a39..64e93d3efe 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js
@@ -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]);
}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js
new file mode 100644
index 0000000000..b5e8c931f1
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js
@@ -0,0 +1,60 @@
+/** @import { AST } from '#compiler' */
+/** @import { Context } from '../types' */
+import * as b from '#compiler/builders';
+import * as e from '../../../errors.js';
+
+/**
+ * @param {AST.DeclarationTag} node
+ * @param {Context} context
+ */
+export function DeclarationTag(node, context) {
+ 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,
+ // the declaration lives in the fragment scope, which is one level deeper than the
+ // `function_depth` we're tracking here (`set_scope` doesn't update `function_depth`).
+ // align them so that `state_referenced_locally` warnings are calculated correctly
+ function_depth: context.state.scope.function_depth,
+ 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;
+ }
+ }
+}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js
index 5c1e8031b8..ebb2fc2b67 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js
@@ -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];
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
index d92d07b038..c749a4e38f 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
@@ -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, custom_renderer } 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,
@@ -152,6 +141,7 @@ export function client_component(analysis, options) {
scopes: analysis.module.scopes,
is_instance: false,
hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted],
+ templates: new Map(),
node: /** @type {any} */ (null), // populated by the root node
legacy_reactive_imports: [],
legacy_reactive_statements: new Map(),
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js
index 246824a447..ba20f31912 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js
@@ -1,3 +1,4 @@
+/** @import { TemplateLiteral } from 'estree' */
/** @import { Namespace } from '#compiler' */
/** @import { ComponentClientTransformState } from '../types.js' */
/** @import { Node } from './types.js' */
@@ -31,15 +32,30 @@ function build_locations(nodes) {
/**
* @param {ComponentClientTransformState} state
- * @param {Namespace} namespace
+ * @param {string} name
* @param {number} [flags]
*/
-export function transform_template(state, namespace, flags = 0) {
+export function transform_template(state, name, flags = 0) {
+ const namespace = state.metadata.namespace;
// custom renderers needs a tree to work because there's no template element we can use
const tree = state.options.fragments === 'tree' || custom_renderer;
const expression = tree ? state.template.as_tree() : state.template.as_html();
+ const key =
+ tree || dev
+ ? null
+ : get_template_key(
+ /** @type {TemplateLiteral} */ (expression),
+ state.metadata.namespace,
+ flags
+ );
+
+ if (key !== null) {
+ const existing = state.templates.get(key);
+ if (existing !== undefined) return existing;
+ }
+
if (tree) {
if (namespace === 'svg') flags |= TEMPLATE_USE_SVG;
if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML;
@@ -64,5 +80,26 @@ export function transform_template(state, namespace, flags = 0) {
);
}
- return call;
+ const id = state.scope.root.unique(name);
+ state.hoisted.push(b.var(id, call));
+
+ if (key !== null) {
+ state.templates.set(key, id);
+ }
+
+ return id;
+}
+
+/**
+ * Returns a stable key for templates that are safe to deduplicate - plain
+ * `$.from_html`/`from_svg`/`from_mathml` factories with literal arguments - or `null`
+ * for anything else. Dev-mode templates are wrapped in `$.add_locations(...)`, which
+ * embeds per-call-site locations, so they never produce a key and are never shared.
+ * @param {TemplateLiteral} template
+ * @param {Namespace} namespace
+ * @param {number} flags
+ * @returns {string | null}
+ */
+function get_template_key(template, namespace, flags) {
+ return `${namespace} ${flags} ${template.quasis[0].value.raw}`;
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts
index 287bf24ac6..7a95a2d43c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts
+++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts
@@ -40,6 +40,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly analysis: ComponentAnalysis;
readonly options: ValidatedCompileOptions;
readonly hoisted: Array;
+ /** Deduplicates hoisted templates by content, mapping a template key to its hoisted identifier */
+ readonly templates: Map;
readonly events: Set;
readonly store_to_invalidate?: string;
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js
index f21fb43fc1..d52b438d92 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js
@@ -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;
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
index bf559cd24d..d05d7a8ed9 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js
@@ -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);
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js
new file mode 100644
index 0000000000..abec5e828f
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js
@@ -0,0 +1,89 @@
+/** @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) {
+ // register the transformers _before_ visiting the declaration, so that
+ // later declarators can reference earlier ones (e.g. `{let a = $state(0), b = $derived(a * 2)}`)
+ add_state_transformers(context);
+ const declaration = /** @type {Statement | undefined} */ (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.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));
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
index ad0c487fb4..893b1db568 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js
@@ -52,7 +52,6 @@ export function Fragment(node, context) {
(trimmed[0].type === 'IfBlock' &&
trimmed[0].elseif &&
/** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0])));
- const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent
/** @type {Statement[]} */
const body = [];
@@ -96,8 +95,7 @@ export function Fragment(node, context) {
let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined;
- const template = transform_template(state, namespace, flags);
- state.hoisted.push(b.var(template_name, template));
+ const template_name = transform_template(state, 'root', flags);
state.init.unshift(b.var(id, b.call(template_name)));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
@@ -147,8 +145,7 @@ export function Fragment(node, context) {
// special case — we can use `$.comment` instead of creating a unique template
state.init.unshift(b.var(id, b.call('$.comment')));
} else {
- const template = transform_template(state, namespace, flags);
- state.hoisted.push(b.var(template_name, template));
+ const template_name = transform_template(state, 'root', flags);
state.init.unshift(b.var(id, b.call(template_name)));
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
index 040bf4385d..abff9b2394 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
@@ -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,
@@ -303,11 +303,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'
};
@@ -321,8 +324,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);
@@ -379,7 +393,6 @@ export function RegularElement(node, context) {
context.state.template.push_comment();
// Create a separate template for the rich content
- const template_name = context.state.scope.root.unique(`${name}_content`);
const fragment_id = b.id(context.state.scope.generate('fragment'));
const anchor_id = b.id(context.state.scope.generate('anchor'));
@@ -403,9 +416,8 @@ export function RegularElement(node, context) {
}
);
- // Transform the template to $.from_html(...) and hoist it
- const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT);
- context.state.hoisted.push(b.var(template_name, template));
+ // Transform the template to $.from_html(...) and hoist it (deduplicating identical templates)
+ const template_name = transform_template(select_state, `${name}_content`, TEMPLATE_FRAGMENT);
// Build the rich content function body
// The anchor is the child of the element (a hydration marker during hydration)
@@ -446,11 +458,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,
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
index f929a3bc47..eaffd15084 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
@@ -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[]} */
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
index 72685a8e83..b9f4690179 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
@@ -49,8 +49,13 @@ export function VariableDeclaration(node, context) {
}
if (declarator.id.type === 'Identifier') {
+ const exclude_id = context.state.scope.root.unique('rest_excludes');
+ context.state.hoisted.push(
+ b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name)))))
+ );
+
/** @type {Expression[]} */
- const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))];
+ const args = [b.id('$$props'), exclude_id];
if (dev) {
// include rest name, so we can provide informative error messages
@@ -95,8 +100,13 @@ export function VariableDeclaration(node, context) {
}
} else {
// RestElement
+ const exclude_id = context.state.scope.root.unique('rest_excludes');
+ context.state.hoisted.push(
+ b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name)))))
+ );
+
/** @type {Expression[]} */
- const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))];
+ const args = [b.id('$$props'), exclude_id];
if (dev) {
// include rest name, so we can provide informative error messages
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
index 6d1de18708..2101ea04e8 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
@@ -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,
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts
index 4912728a1e..2863955786 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts
+++ b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts
@@ -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[];
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
index 9420bdd6d2..cf56ef6910 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js
@@ -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));
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js
new file mode 100644
index 0000000000..8338700c0b
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js
@@ -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));
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js
index 6b4db1e819..fdf0d22b1c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js
@@ -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
);
@@ -209,7 +216,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)])
);
diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js
index f61b59f3bd..c7fce61ff9 100644
--- a/packages/svelte/src/compiler/phases/3-transform/utils.js
+++ b/packages/svelte/src/compiler/phases/3-transform/utils.js
@@ -152,6 +152,7 @@ export function clean_nodes(
if (
node.type === 'ConstTag' ||
+ node.type === 'DeclarationTag' ||
node.type === 'DebugTag' ||
node.type === 'SvelteBody' ||
node.type === 'SvelteWindow' ||
diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js
index 48ebacb50b..da7e73015b 100644
--- a/packages/svelte/src/compiler/phases/nodes.js
+++ b/packages/svelte/src/compiler/phases/nodes.js
@@ -217,6 +217,7 @@ function* find_descendants(fragment) {
case 'SnippetBlock':
case 'DebugTag':
case 'ConstTag':
+ case 'DeclarationTag':
case 'Comment':
case 'ExpressionTag':
break;
diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js
index c511ee6da0..fbd7e86f12 100644
--- a/packages/svelte/src/compiler/print/index.js
+++ b/packages/svelte/src/compiler/print/index.js
@@ -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;
diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts
index c2c79f2b57..6d3ce17d03 100644
--- a/packages/svelte/src/compiler/types/template.d.ts
+++ b/packages/svelte/src/compiler/types/template.d.ts
@@ -161,6 +161,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';
@@ -623,6 +635,7 @@ export namespace AST {
export type Tag =
| AST.AttachTag
| AST.ConstTag
+ | AST.DeclarationTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag
diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js
index 6ac1356d8f..ad5a4ee418 100644
--- a/packages/svelte/src/compiler/utils/builders.js
+++ b/packages/svelte/src/compiler/utils/builders.js
@@ -686,7 +686,8 @@ export {
if_builder as if,
this_instance as this,
null_instance as null,
- debugger_builder as debugger
+ debugger_builder as debugger,
+ new_builder as new
};
/**
diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js
index 737d859b93..6b5ec4f9c7 100644
--- a/packages/svelte/src/internal/client/dom/blocks/boundary.js
+++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js
@@ -417,7 +417,7 @@ export class Boundary {
if (this.#pending_effect) current_batch.skip_effect(this.#pending_effect);
if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect);
- current_batch.on_fork_commit(() => {
+ current_batch.oncommit(() => {
this.#handle_error(error);
});
} else {
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 6a5ed9f01f..d47610fa9a 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -242,7 +242,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var each_array = derived_safe_equal(() => {
var collection = get_collection();
- return is_array(collection) ? collection : collection == null ? [] : array_from(collection);
+ return /** @type {V[]} */ (
+ is_array(collection) ? collection : collection == null ? [] : array_from(collection)
+ );
});
if (DEV) {
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 7e4e30d2c0..52ef9c95d6 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -343,6 +343,21 @@ function set_attributes(
var setters = get_setters(element);
+ if (
+ // the following login only make sense in a dev environment
+ current_renderer == null &&
+ node_name(element) === INPUT_TAG &&
+ 'type' in next &&
+ ('value' in next || '__value' in next)
+ ) {
+ var type = next.type;
+
+ if (type !== current.type || (type === undefined && element.hasAttribute('type'))) {
+ current.type = type;
+ set_attribute(element, 'type', type, skip_warning);
+ }
+ }
+
// since key is captured we use const
for (const key in next) {
// let instead of var because referenced in a closure
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
index 55e61c3774..dadcac369c 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
@@ -7,7 +7,6 @@ import { is } from '../../../proxy.js';
import { queue_micro_task } from '../../task.js';
import { hydrating } from '../../hydration.js';
import { tick, untrack } from '../../../runtime.js';
-import { is_runes } from '../../../context.js';
import { current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 6f5d0d35e9..2f2d741447 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -1,4 +1,4 @@
-/** @import { Blocker, Effect, Value } from '#client' */
+/** @import { Blocker, Effect, Source, Value } from '#client' */
import { DESTROYED, STALE_REACTION } from '#client/constants';
import { DEV } from 'esm-env';
import {
@@ -39,8 +39,22 @@ export function flatten(blockers, sync, async, fn) {
// Filter out already-settled blockers - no need to wait for them
var pending = blockers.filter((b) => !b.settled);
+ var deriveds = sync.map(d);
+
+ if (DEV) {
+ deriveds.forEach((d, i) => {
+ // TODO this is kinda useful for debugging but a lousy implementation —
+ // maybe the compiler could pass through the template string
+ d.label = sync[i]
+ .toString()
+ .replace('() => ', '')
+ .replaceAll('$.eager(() => ', '$state.eager(')
+ .replace(/\$\.get\((.+?)\)/g, (_, id) => id);
+ });
+ }
+
if (async.length === 0 && pending.length === 0) {
- fn(sync.map(d));
+ fn(deriveds);
return;
}
@@ -54,8 +68,10 @@ export function flatten(blockers, sync, async, fn) {
? Promise.all(pending.map((b) => b.promise))
: null;
- /** @param {Value[]} values */
- function finish(values) {
+ /**
+ * @param {Source[]} async
+ */
+ function finish(async) {
if ((parent.f & DESTROYED) !== 0) {
return;
}
@@ -63,7 +79,7 @@ export function flatten(blockers, sync, async, fn) {
restore();
try {
- fn(values);
+ fn([...deriveds, ...async]);
} catch (error) {
invoke_error_boundary(error, parent);
}
@@ -75,17 +91,14 @@ export function flatten(blockers, sync, async, fn) {
// Fast path: blockers but no async expressions
if (async.length === 0) {
- /** @type {Promise} */ (blocker_promise)
- .then(() => finish(sync.map(d)))
- .finally(decrement_pending);
-
+ /** @type {Promise} */ (blocker_promise).then(() => finish([])).finally(decrement_pending);
return;
}
// Full path: has async expressions
function run() {
Promise.all(async.map((expression) => async_derived(expression)))
- .then((result) => finish([...sync.map(d), ...result]))
+ .then(finish)
.catch((error) => invoke_error_boundary(error, parent))
.finally(decrement_pending);
}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 8dbcacf429..3b82059788 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -140,12 +140,6 @@ export class Batch {
*/
#discard_callbacks = new Set();
- /**
- * Callbacks that should run only when a fork is committed.
- * @type {Set<(batch: Batch) => void>}
- */
- #fork_commit_callbacks = new Set();
-
/**
* The number of async effects that are currently in flight
*/
@@ -634,7 +628,6 @@ export class Batch {
discard() {
for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear();
- this.#fork_commit_callbacks.clear();
this.#unlink();
this.#deferred?.resolve();
@@ -840,16 +833,6 @@ export class Batch {
this.#discard_callbacks.add(fn);
}
- /** @param {(batch: Batch) => void} fn */
- on_fork_commit(fn) {
- this.#fork_commit_callbacks.add(fn);
- }
-
- run_fork_commit_callbacks() {
- for (const fn of this.#fork_commit_callbacks) fn(this);
- this.#fork_commit_callbacks.clear();
- }
-
settled() {
return (this.#deferred ??= deferred()).promise;
}
@@ -1410,10 +1393,6 @@ export function fork(fn) {
source.wv = increment_write_version();
}
- batch.activate();
- batch.run_fork_commit_callbacks();
- batch.deactivate();
-
// trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we
// can't just encounter them during traversal, we need to
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 376783a7db..b14b129066 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -389,7 +389,9 @@ export function render_effect(fn, flags = 0) {
*/
export function template_effect(fn, sync = [], async = [], blockers = []) {
flatten(blockers, sync, async, (values) => {
- create_effect(RENDER_EFFECT, () => fn(...values.map(get)));
+ create_effect(RENDER_EFFECT, () => {
+ fn(...values.map(get));
+ });
});
}
@@ -522,7 +524,7 @@ export function destroy_effect(effect, remove_dom = true) {
removed = true;
}
- set_signal_status(effect, DESTROYING);
+ effect.f |= DESTROYING;
destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0);
diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js
index 5626639a84..c2f3698809 100644
--- a/packages/svelte/src/internal/client/reactivity/props.js
+++ b/packages/svelte/src/internal/client/reactivity/props.js
@@ -49,11 +49,11 @@ export function update_pre_prop(fn, d = 1) {
/**
* The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`).
* Is passed the full `$$props` object and excludes the named props.
- * @type {ProxyHandler<{ props: Record, exclude: Array, name?: string }>}}
+ * @type {ProxyHandler<{ props: Record, exclude: Set, name?: string }>}}
*/
const rest_props_handler = {
get(target, key) {
- if (target.exclude.includes(key)) return;
+ if (target.exclude.has(key)) return;
return target.props[key];
},
set(target, key) {
@@ -65,7 +65,7 @@ const rest_props_handler = {
return false;
},
getOwnPropertyDescriptor(target, key) {
- if (target.exclude.includes(key)) return;
+ if (target.exclude.has(key)) return;
if (key in target.props) {
return {
enumerable: true,
@@ -75,17 +75,17 @@ const rest_props_handler = {
}
},
has(target, key) {
- if (target.exclude.includes(key)) return false;
+ if (target.exclude.has(key)) return false;
return key in target.props;
},
ownKeys(target) {
- return Reflect.ownKeys(target.props).filter((key) => !target.exclude.includes(key));
+ return Reflect.ownKeys(target.props).filter((key) => !target.exclude.has(key));
}
};
/**
* @param {Record} props
- * @param {string[]} exclude
+ * @param {Set} exclude
* @param {string} [name]
* @returns {Record}
*/
diff --git a/packages/svelte/src/reactivity/url-search-params.js b/packages/svelte/src/reactivity/url-search-params.js
index 2e70bf518d..38cf3ebe4f 100644
--- a/packages/svelte/src/reactivity/url-search-params.js
+++ b/packages/svelte/src/reactivity/url-search-params.js
@@ -132,11 +132,12 @@ export class SvelteURLSearchParams extends URLSearchParams {
* @returns {void}
*/
set(name, value) {
- var previous = super.getAll(name).join('');
+ var previous = super.getAll(name);
super.set(name, value);
// can't use has(name, value), because for something like https://svelte.dev?foo=1&bar=2&foo=3
// if you set `foo` to 1, then foo=3 gets deleted whilst `has("foo", "1")` returns true
- if (previous !== super.getAll(name).join('')) {
+ var current = super.getAll(name);
+ if (previous.length !== current.length || previous.some((value, i) => value !== current[i])) {
this.#update_url();
increment(this.#version);
}
diff --git a/packages/svelte/src/reactivity/url-search-params.test.ts b/packages/svelte/src/reactivity/url-search-params.test.ts
index 27a413a553..b0c84872b0 100644
--- a/packages/svelte/src/reactivity/url-search-params.test.ts
+++ b/packages/svelte/src/reactivity/url-search-params.test.ts
@@ -55,6 +55,44 @@ test('URLSearchParams.set', () => {
cleanup();
});
+test('URLSearchParams.set updates when duplicate values collapse to the same joined string', () => {
+ const params = new SvelteURLSearchParams('a=ab&a=c');
+ const log: any = [];
+
+ const cleanup = effect_root(() => {
+ render_effect(() => {
+ log.push(params.toString());
+ });
+ });
+
+ flushSync(() => {
+ params.set('a', 'abc');
+ });
+
+ assert.deepEqual(log, ['a=ab&a=c', 'a=abc']);
+
+ cleanup();
+});
+
+test('URLSearchParams.set updates when duplicate values collapse to the same comma-joined string', () => {
+ const params = new SvelteURLSearchParams('a=a&a=b');
+ const log: any = [];
+
+ const cleanup = effect_root(() => {
+ render_effect(() => {
+ log.push(params.toString());
+ });
+ });
+
+ flushSync(() => {
+ params.set('a', 'a,b');
+ });
+
+ assert.deepEqual(log, ['a=a&a=b', 'a=a%2Cb']);
+
+ cleanup();
+});
+
test('URLSearchParams.append', () => {
const params = new SvelteURLSearchParams();
const log: any = [];
diff --git a/packages/svelte/src/reactivity/url.test.ts b/packages/svelte/src/reactivity/url.test.ts
index a76efeb2b2..a79aa32315 100644
--- a/packages/svelte/src/reactivity/url.test.ts
+++ b/packages/svelte/src/reactivity/url.test.ts
@@ -115,6 +115,25 @@ test('url.searchParams', () => {
cleanup();
});
+test('url.searchParams.set updates url when duplicate values collapse to the same joined string', () => {
+ const url = new SvelteURL('https://svelte.dev?a=ab&a=c');
+ const log: any = [];
+
+ const cleanup = effect_root(() => {
+ render_effect(() => {
+ log.push(url.href);
+ });
+ });
+
+ flushSync(() => {
+ url.searchParams.set('a', 'abc');
+ });
+
+ assert.deepEqual(log, ['https://svelte.dev/?a=ab&a=c', 'https://svelte.dev/?a=abc']);
+
+ cleanup();
+});
+
test('url.search normalizes value', () => {
const url = new SvelteURL('https://svelte.dev');
const log: any = [];
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 638534d369..1b080d134a 100644
--- a/packages/svelte/src/version.js
+++ b/packages/svelte/src/version.js
@@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
-export const VERSION = '5.55.10';
+export const VERSION = '5.56.0';
export const PUBLIC_VERSION = '5';
diff --git a/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/_config.js b/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/_config.js
new file mode 100644
index 0000000000..2e605631c5
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/_config.js
@@ -0,0 +1,11 @@
+import { test } from '../../test';
+
+// A tag whose expression ends with a bare `/` at the end of the input used to
+// make `find_matching_bracket` loop forever; it should error instead of hanging.
+export default test({
+ error: {
+ code: 'unexpected_eof',
+ message: 'Unexpected end of input',
+ position: [12, 12]
+ }
+});
diff --git a/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/main.svelte b/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/main.svelte
new file mode 100644
index 0000000000..8ffdb88a18
--- /dev/null
+++ b/packages/svelte/tests/compiler-errors/samples/declaration-tag-trailing-slash/main.svelte
@@ -0,0 +1 @@
+{let x = a /
\ No newline at end of file
diff --git a/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/input.svelte b/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/input.svelte
new file mode 100644
index 0000000000..036d5283f5
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/input.svelte
@@ -0,0 +1,5 @@
+{#if true}
+ {let }
+ {const x = }
+ {let x = a / }
+{/if}
diff --git a/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/output.json b/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/output.json
new file mode 100644
index 0000000000..270d16af94
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/loose-declaration-tag/output.json
@@ -0,0 +1,145 @@
+{
+ "css": null,
+ "js": [],
+ "start": 0,
+ "end": 54,
+ "type": "Root",
+ "fragment": {
+ "type": "Fragment",
+ "nodes": [
+ {
+ "type": "IfBlock",
+ "elseif": false,
+ "start": 0,
+ "end": 54,
+ "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": 48,
+ "declaration": {
+ "type": "VariableDeclaration",
+ "kind": "let",
+ "declarations": [
+ {
+ "type": "VariableDeclarator",
+ "id": {
+ "type": "Identifier",
+ "name": "",
+ "start": 47,
+ "end": 47
+ },
+ "init": null,
+ "start": 47,
+ "end": 47
+ }
+ ],
+ "start": 35,
+ "end": 47
+ }
+ },
+ {
+ "type": "Text",
+ "start": 48,
+ "end": 49,
+ "raw": "\n",
+ "data": "\n"
+ }
+ ]
+ },
+ "alternate": null
+ }
+ ]
+ },
+ "options": null
+}
diff --git a/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/input.svelte b/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/input.svelte
new file mode 100644
index 0000000000..fa4195996e
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/input.svelte
@@ -0,0 +1 @@
+{type instanceof /* probe */ Object}
diff --git a/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/output.json b/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/output.json
new file mode 100644
index 0000000000..4fe6468742
--- /dev/null
+++ b/packages/svelte/tests/parser-modern/samples/tag-type-identifier-probe-comment/output.json
@@ -0,0 +1,92 @@
+{
+ "css": null,
+ "js": [],
+ "start": 0,
+ "end": 36,
+ "type": "Root",
+ "fragment": {
+ "type": "Fragment",
+ "nodes": [
+ {
+ "type": "ExpressionTag",
+ "start": 0,
+ "end": 36,
+ "expression": {
+ "type": "BinaryExpression",
+ "start": 1,
+ "end": 35,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 1
+ },
+ "end": {
+ "line": 1,
+ "column": 35
+ }
+ },
+ "left": {
+ "type": "Identifier",
+ "start": 1,
+ "end": 5,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 1
+ },
+ "end": {
+ "line": 1,
+ "column": 5
+ }
+ },
+ "name": "type"
+ },
+ "operator": "instanceof",
+ "right": {
+ "type": "Identifier",
+ "start": 29,
+ "end": 35,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 29
+ },
+ "end": {
+ "line": 1,
+ "column": 35
+ }
+ },
+ "name": "Object",
+ "leadingComments": [
+ {
+ "type": "Block",
+ "value": " probe ",
+ "start": 17,
+ "end": 28
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "options": null,
+ "comments": [
+ {
+ "type": "Block",
+ "value": " probe ",
+ "start": 17,
+ "end": 28,
+ "loc": {
+ "start": {
+ "line": 1,
+ "column": 17
+ },
+ "end": {
+ "line": 1,
+ "column": 28
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/svelte/tests/print/samples/declaration-tag/input.svelte b/packages/svelte/tests/print/samples/declaration-tag/input.svelte
new file mode 100644
index 0000000000..6dbb2af2dd
--- /dev/null
+++ b/packages/svelte/tests/print/samples/declaration-tag/input.svelte
@@ -0,0 +1,7 @@
+{#if visible}
+ {let count = 1}
+ {const doubled = count * 2}
+ {const label = 'count'}
+ {const format = (value) => `${label}: ${value}`}
+ {format(doubled)}
+{/if}
diff --git a/packages/svelte/tests/print/samples/declaration-tag/output.svelte b/packages/svelte/tests/print/samples/declaration-tag/output.svelte
new file mode 100644
index 0000000000..6dbb2af2dd
--- /dev/null
+++ b/packages/svelte/tests/print/samples/declaration-tag/output.svelte
@@ -0,0 +1,7 @@
+{#if visible}
+ {let count = 1}
+ {const doubled = count * 2}
+ {const label = 'count'}
+ {const format = (value) => `${label}: ${value}`}
+ {format(doubled)}
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js
new file mode 100644
index 0000000000..0dd4b870d5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js
@@ -0,0 +1,13 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ mode: ['async-server', 'client', 'hydrate'],
+ ssrHtml: `Hello, world! 5 01234 5 sync 6 5 0 10`,
+
+ async test({ assert, target }) {
+ await tick();
+
+ assert.htmlEqual(target.innerHTML, `Hello, world! 5 01234 5 sync 6 5 0 10`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte
new file mode 100644
index 0000000000..f15065e4db
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {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,
+ `
+ name
+ change name
+ Hello name
+ nested Hi name
+ `
+ );
+
+ top.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ other
+ change name
+ Hello name
+ nested Hi name
+ `
+ );
+
+ change.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ other
+ change name
+ 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)}
+ name = 'other'}>{name}
+
+{#if id}
+ {let name = $state(await id)}
+ {let greeting = $derived(await `Hello ${name}`)}
+
+ name = 'other'}>change name
+ {greeting}
+
+ {const nested = 'nested'}
+ {const greeting2 = $derived(await `Hi ${name}`)}
+ {nested} {greeting2}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/_config.js b/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/_config.js
new file mode 100644
index 0000000000..c5846ba0bc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/_config.js
@@ -0,0 +1,17 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: `increment count: 0
doubled: 0
quadrupled: 0
`,
+ async test({ assert, target }) {
+ const [button] = target.querySelectorAll('button');
+
+ button.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `increment count: 1
doubled: 2
quadrupled: 4
`
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/main.svelte b/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/main.svelte
new file mode 100644
index 0000000000..d6fb542396
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/declaration-tag-multiple-declarators/main.svelte
@@ -0,0 +1,10 @@
+
+{let count = $state(0), doubled = $derived(count * 2)}
+
+
+{ let quadrupled = $derived(doubled * 2) }
+
+ (count += 1)}>increment
+count: {count}
+doubled: {doubled}
+quadrupled: {quadrupled}
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: '0 | 0 ',
+ async test({ assert, target }) {
+ const [increment] = target.querySelectorAll('button');
+
+ increment.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, '1 | 2 ');
+ }
+});
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)}
+ (count += 1)}>{count} | {doubled}
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: `top 2 toggle 2 4 total
nested
nested
`,
+ async test({ assert, target }) {
+ const [top, toggle, increment] = target.querySelectorAll('button');
+
+ top.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `top 4 toggle 2 4 total
nested
nested
`
+ );
+
+ increment.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `top 4 toggle 3 6 total
nested
nested
`
+ );
+
+ toggle.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `top 4 toggle nested
`
+ );
+
+ toggle.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `top 4 toggle 2 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)}
+
+ (top += 1)}>top {top_doubled}
+ (visible = !visible)}>toggle
+
+{#if visible}
+ {let counter = $state({ value: initial })}
+ {let doubled = $derived(counter.value * 2)}
+ {const suffix = ' total'}
+ {const format = (value) => `${value}${suffix}`}
+
+ (counter.value += 1)}>{counter.value}
+ {format(doubled)}
+
+ {const doubled = 'nested'}
+ {doubled}
+
+{/if}
+
+
+ {const nested = 'nested'}
+ {nested}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/_config.js b/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/_config.js
new file mode 100644
index 0000000000..7aeaf38eb0
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/_config.js
@@ -0,0 +1,15 @@
+import { test } from '../../test';
+
+const value = 'line1\nline2\nline3';
+
+export default test({
+ mode: ['client'],
+ test({ assert, target }) {
+ const [spread_first, type_first] = target.querySelectorAll('input');
+
+ assert.equal(spread_first?.type, 'hidden');
+ assert.equal(spread_first?.value, value);
+ assert.equal(type_first?.type, 'hidden');
+ assert.equal(type_first?.value, value);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/main.svelte b/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/main.svelte
new file mode 100644
index 0000000000..16e72ab704
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/input-type-before-value-spread/main.svelte
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js
index 0e2ceae197..a89ddd94eb 100644
--- a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js
@@ -3,7 +3,7 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/async';
import * as $ from 'svelte/internal/client';
-var root_1 = $.from_html(`
`);
+var root = $.from_html(`
`);
export default function Async_const($$anchor) {
var fragment = $.comment();
@@ -19,7 +19,7 @@ export default function Async_const($$anchor) {
() => b = $.derived(() => $.get(a) + 1)
]);
- var p = root_1();
+ var p = root();
var text = $.child(p, true);
$.reset(p);
diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js
new file mode 100644
index 0000000000..d0d474fa29
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js
@@ -0,0 +1,44 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+var root = $.from_html(`hello
`);
+var root_1 = $.from_html(` `, 1);
+
+export default function Dedupe_templates($$anchor, $$props) {
+ var fragment = root_1();
+ var node = $.first_child(fragment);
+
+ {
+ var consequent = ($$anchor) => {
+ var p = root();
+
+ $.append($$anchor, p);
+ };
+
+ var alternate = ($$anchor) => {
+ var p_1 = root();
+
+ $.append($$anchor, p_1);
+ };
+
+ $.if(node, ($$render) => {
+ if ($$props.a) $$render(consequent); else $$render(alternate, -1);
+ });
+ }
+
+ var node_1 = $.sibling(node, 2);
+
+ {
+ var consequent_1 = ($$anchor) => {
+ var p_2 = root();
+
+ $.append($$anchor, p_2);
+ };
+
+ $.if(node_1, ($$render) => {
+ if ($$props.b) $$render(consequent_1);
+ });
+ }
+
+ $.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js
new file mode 100644
index 0000000000..1f4a56779e
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js
@@ -0,0 +1,24 @@
+import * as $ from 'svelte/internal/server';
+
+export default function Dedupe_templates($$renderer, $$props) {
+ let { a, b } = $$props;
+
+ if (a) {
+ $$renderer.push('');
+ $$renderer.push(`hello
`);
+ } else {
+ $$renderer.push('');
+ $$renderer.push(`hello
`);
+ }
+
+ $$renderer.push(` `);
+
+ if (b) {
+ $$renderer.push('');
+ $$renderer.push(`hello
`);
+ } else {
+ $$renderer.push('');
+ }
+
+ $$renderer.push(``);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte b/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte
new file mode 100644
index 0000000000..c75b5481a5
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte
@@ -0,0 +1,14 @@
+
+
+
+{#if a}
+ hello
+{:else}
+ hello
+{/if}
+
+{#if b}
+ hello
+{/if}
diff --git a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js
index 98fc065144..630d9874a4 100644
--- a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js
@@ -3,14 +3,14 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
-var root_1 = $.from_html(`B `);
+var root = $.from_html(`B `);
export default function Delegated_locally_declared_shadowed($$anchor) {
var fragment = $.comment();
var node = $.first_child(fragment);
$.each(node, 0, () => ({ length: 1 }), $.index, ($$anchor, $$item, index) => {
- var button = root_1();
+ var button = root();
$.set_attribute(button, 'data-index', index);
diff --git a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js
index 36b446034b..fe4f245169 100644
--- a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js
@@ -3,14 +3,14 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
-var root_1 = $.from_html(`
`);
+var root = $.from_html(`
`);
export default function Each_index_non_null($$anchor) {
var fragment = $.comment();
var node = $.first_child(fragment);
$.each(node, 0, () => Array(10), $.index, ($$anchor, $$item, i) => {
- var p = root_1();
+ var p = root();
p.textContent = `index: ${i}`;
$.append($$anchor, p);
diff --git a/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js
index 6f7b2ef3ef..94e3606901 100644
--- a/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js
@@ -2,10 +2,12 @@ import 'svelte/internal/init-operations';
import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
+var rest_excludes = new Set(['$$slots', '$$events', '$$legacy']);
+
export default function Props_identifier($$anchor, $$props) {
$.push($$props, true);
- let props = $.rest_props($$props, ['$$slots', '$$events', '$$legacy']);
+ let props = $.rest_props($$props, rest_excludes);
$$props.a;
props[a];
diff --git a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js
index 78feced63d..42468c4e04 100644
--- a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js
+++ b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js
@@ -5,63 +5,51 @@ import * as $ from 'svelte/internal/client';
import Option from './Option.svelte';
const opt = ($$anchor) => {
- var option = root_1();
+ var option = root();
$.append($$anchor, option);
};
const option_snippet = ($$anchor) => {
- var option_1 = root_2();
+ var option_1 = root_1();
$.append($$anchor, option_1);
};
const option_snippet2 = ($$anchor) => {
- var option_2 = root_3();
+ var option_2 = root_2();
$.append($$anchor, option_2);
};
const conditional_option = ($$anchor) => {
- var option_3 = root_4();
+ var option_3 = root_3();
$.append($$anchor, option_3);
};
-var root_1 = $.from_html(`Snippet `);
-var root_2 = $.from_html(`Rendered `);
-var root_3 = $.from_html(`Rendered in group `);
-var root_4 = $.from_html(`Conditional `);
+var root = $.from_html(`Snippet `);
+var root_1 = $.from_html(`Rendered `);
+var root_2 = $.from_html(`Rendered in group `);
+var root_3 = $.from_html(`Conditional `);
var option_content = $.from_html(`Rich `, 1);
-var root_5 = $.from_html(` `);
-var root_6 = $.from_html(`Visible `);
-var root_7 = $.from_html(`Keyed `);
+var root_4 = $.from_html(` `);
+var root_5 = $.from_html(`Visible `);
+var root_6 = $.from_html(`Keyed `);
var select_content = $.from_html(``, 1);
-var root_8 = $.from_html(` `);
var option_content_1 = $.from_html(`Bold `, 1);
-var root_9 = $.from_html(` `);
var option_content_2 = $.from_html(`Italic text`, 1);
var option_content_3 = $.from_html(` `, 1);
-var root_10 = $.from_html(` `);
-var root_12 = $.from_html(` `);
-var root_13 = $.from_html(`Boundary `);
+var root_7 = $.from_html(` `);
+var root_8 = $.from_html(`Boundary `);
var option_content_4 = $.from_html(`Rich in boundary `, 1);
-var root_14 = $.from_html(` `);
-var select_content_1 = $.from_html(``, 1);
-var select_content_2 = $.from_html(``, 1);
-var select_content_3 = $.from_html(``, 1);
-var optgroup_content = $.from_html(``, 1);
-var optgroup_content_1 = $.from_html(``, 1);
-var option_content_5 = $.from_html(``, 1);
-var select_content_4 = $.from_html(``, 1);
-var select_content_5 = $.from_html(``, 1);
-var root = $.from_html(` `, 1);
+var root_9 = $.from_html(` `, 1);
export default function Select_with_rich_content($$anchor) {
let items = [1, 2, 3];
let show = true;
let html = 'From HTML ';
- var fragment = root();
+ var fragment = root_9();
var select = $.first_child(fragment);
var option_4 = $.child(select);
@@ -77,7 +65,7 @@ export default function Select_with_rich_content($$anchor) {
var select_1 = $.sibling(select, 2);
$.each(select_1, 5, () => items, $.index, ($$anchor, item) => {
- var option_5 = root_5();
+ var option_5 = root_4();
var text = $.child(option_5, true);
$.reset(option_5);
@@ -102,7 +90,7 @@ export default function Select_with_rich_content($$anchor) {
{
var consequent = ($$anchor) => {
- var option_6 = root_6();
+ var option_6 = root_5();
$.append($$anchor, option_6);
};
@@ -118,7 +106,7 @@ export default function Select_with_rich_content($$anchor) {
var node_1 = $.child(select_3);
$.key(node_1, () => items, ($$anchor) => {
- var option_7 = root_7();
+ var option_7 = root_6();
$.append($$anchor, option_7);
});
@@ -140,7 +128,7 @@ export default function Select_with_rich_content($$anchor) {
$.each(select_5, 5, () => items, $.index, ($$anchor, item) => {
const x = $.derived_safe_equal(() => $.get(item) * 2);
- var option_8 = root_8();
+ var option_8 = root_4();
var text_1 = $.child(option_8, true);
$.reset(option_8);
@@ -178,7 +166,7 @@ export default function Select_with_rich_content($$anchor) {
var optgroup_1 = $.child(select_7);
$.each(optgroup_1, 5, () => items, $.index, ($$anchor, item) => {
- var option_10 = root_9();
+ var option_10 = root_4();
var text_2 = $.child(option_10, true);
$.reset(option_10);
@@ -216,7 +204,7 @@ export default function Select_with_rich_content($$anchor) {
var select_9 = $.sibling(select_8, 2);
$.each(select_9, 5, () => items, $.index, ($$anchor, item) => {
- var option_12 = root_10();
+ var option_12 = root_7();
$.customizable_select(option_12, () => {
var anchor_4 = $.child(option_12);
@@ -243,7 +231,7 @@ export default function Select_with_rich_content($$anchor) {
var node_4 = $.first_child(fragment_6);
$.each(node_4, 1, () => items, $.index, ($$anchor, item) => {
- var option_13 = root_12();
+ var option_13 = root_4();
var text_4 = $.child(option_13, true);
$.reset(option_13);
@@ -275,7 +263,7 @@ export default function Select_with_rich_content($$anchor) {
var node_5 = $.child(select_11);
$.boundary(node_5, {}, ($$anchor) => {
- var option_14 = root_13();
+ var option_14 = root_8();
$.append($$anchor, option_14);
});
@@ -286,7 +274,7 @@ export default function Select_with_rich_content($$anchor) {
var node_6 = $.child(select_12);
$.boundary(node_6, {}, ($$anchor) => {
- var option_15 = root_14();
+ var option_15 = root_7();
$.customizable_select(option_15, () => {
var anchor_5 = $.child(option_15);
@@ -304,7 +292,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(select_13, () => {
var anchor_6 = $.child(select_13);
- var fragment_8 = select_content_1();
+ var fragment_8 = select_content();
var node_7 = $.first_child(fragment_8);
Option(node_7, {});
@@ -315,7 +303,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(select_14, () => {
var anchor_7 = $.child(select_14);
- var fragment_9 = select_content_2();
+ var fragment_9 = select_content();
var node_8 = $.first_child(fragment_9);
option_snippet(node_8);
@@ -326,7 +314,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(select_15, () => {
var anchor_8 = $.child(select_15);
- var fragment_10 = select_content_3();
+ var fragment_10 = select_content();
var node_9 = $.first_child(fragment_10);
$.html(node_9, () => html);
@@ -338,7 +326,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(optgroup_2, () => {
var anchor_9 = $.child(optgroup_2);
- var fragment_11 = optgroup_content();
+ var fragment_11 = select_content();
var node_10 = $.first_child(fragment_11);
Option(node_10, {});
@@ -352,7 +340,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(optgroup_3, () => {
var anchor_10 = $.child(optgroup_3);
- var fragment_12 = optgroup_content_1();
+ var fragment_12 = select_content();
var node_11 = $.first_child(fragment_12);
option_snippet2(node_11);
@@ -366,7 +354,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(option_16, () => {
var anchor_11 = $.child(option_16);
- var fragment_13 = option_content_5();
+ var fragment_13 = select_content();
var node_12 = $.first_child(fragment_13);
$.html(node_12, () => 'Bold HTML ');
@@ -379,7 +367,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(select_19, () => {
var anchor_12 = $.child(select_19);
- var fragment_14 = select_content_4();
+ var fragment_14 = select_content();
var node_13 = $.first_child(fragment_14);
$.each(node_13, 1, () => items, $.index, ($$anchor, item) => {
@@ -393,7 +381,7 @@ export default function Select_with_rich_content($$anchor) {
$.customizable_select(select_20, () => {
var anchor_13 = $.child(select_20);
- var fragment_16 = select_content_5();
+ var fragment_16 = select_content();
var node_14 = $.first_child(fragment_16);
{
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..fe51488c70
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-function/errors.json
@@ -0,0 +1 @@
+[]
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-2/errors.json b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type-2/errors.json
new file mode 100644
index 0000000000..a375ac5776
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type-2/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "declaration_tag_invalid_type",
+ "message": "Declaration tags must be `let` or `const` declarations",
+ "start": {
+ "line": 28,
+ "column": 2
+ },
+ "end": {
+ "line": 28,
+ "column": 20
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/declaration-tag-invalid-type-2/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type-2/input.svelte
new file mode 100644
index 0000000000..88295cad58
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-invalid-type-2/input.svelte
@@ -0,0 +1,29 @@
+
+
+{#if true}
+
+ {type}
+ {type }
+ {type && foo}
+ {type || bar}
+ {type % 2}
+ {type .x}
+ {type ?.x}
+ {type ()}
+ {type [1]}
+ {type `tag`}
+ {type === 'all' ? 'All types' : type}
+ {type !== 'all'}
+ {type == 'all'}
+ {type != 'all'}
+ {type + 1}
+ {type - 1}
+ {type * 2}
+ {type / 2}
+ {type > 1}
+ {type instanceof Foo}
+ {type instanceof /* comment */ Object}
+ {type in foo}
+
+ {type foo = boolean}
+{/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/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte
new file mode 100644
index 0000000000..90cc208e72
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/input.svelte
@@ -0,0 +1,9 @@
+
+{let a = $state(0), b = $derived(a * 2)}
+{let c = $state(0)}
+{let d = $derived(c * 2)}
+
+
+{let e = $state(0), f = e}
+
+{a}{b}{c}{d}{e}{f}
diff --git a/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/warnings.json b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/warnings.json
new file mode 100644
index 0000000000..9b87a3afb6
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally/warnings.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "state_referenced_locally",
+ "message": "This reference only captures the initial value of `e`. Did you mean to reference it inside a closure instead?",
+ "start": {
+ "line": 7,
+ "column": 24
+ },
+ "end": {
+ "line": 7,
+ "column": 25
+ }
+ }
+]
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index dd264d3239..44277c647e 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -1308,6 +1308,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';
@@ -1618,6 +1624,7 @@ declare module 'svelte/compiler' {
export type Tag =
| AST.AttachTag
| AST.ConstTag
+ | AST.DeclarationTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c077f0f6b..767af0515a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -42,8 +42,8 @@ importers:
specifier: 25.0.1
version: 25.0.1
playwright:
- specifier: ^1.58.0
- version: 1.58.0
+ specifier: ^1.60.0
+ version: 1.60.0
prettier:
specifier: ^3.2.4
version: 3.2.4
@@ -121,8 +121,8 @@ importers:
specifier: ^0.3.25
version: 0.3.31
'@playwright/test':
- specifier: ^1.58.0
- version: 1.58.0
+ specifier: ^1.60.0
+ version: 1.60.0
'@rollup/plugin-commonjs':
specifier: ^28.0.1
version: 28.0.1(rollup@4.60.1)
@@ -864,8 +864,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@playwright/test@1.58.0':
- resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==}
+ '@playwright/test@1.60.0':
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
@@ -1096,6 +1096,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1459,8 +1462,8 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- enhanced-resolve@5.20.1:
- resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ enhanced-resolve@5.22.1:
+ resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
@@ -1699,8 +1702,8 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
- get-tsconfig@4.13.7:
- resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
+ get-tsconfig@4.14.0:
+ resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@@ -2162,13 +2165,13 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
- playwright-core@1.58.0:
- resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==}
+ playwright-core@1.60.0:
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
- playwright@1.58.0:
- resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==}
+ playwright@1.60.0:
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
@@ -2307,6 +2310,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.1:
+ resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
@@ -2403,8 +2411,8 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- tapable@2.3.2:
- resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
term-size@2.2.1:
@@ -3283,9 +3291,9 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@playwright/test@1.58.0':
+ '@playwright/test@1.60.0':
dependencies:
- playwright: 1.58.0
+ playwright: 1.60.0
'@polka/url@1.0.0-next.25': {}
@@ -3463,13 +3471,15 @@ snapshots:
'@types/eslint@8.56.12':
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
+ '@types/estree@1.0.9': {}
+
'@types/json-schema@7.0.15': {}
'@types/node@12.20.55': {}
@@ -3840,10 +3850,10 @@ snapshots:
emoji-regex@9.2.2: {}
- enhanced-resolve@5.20.1:
+ enhanced-resolve@5.22.1:
dependencies:
graceful-fs: 4.2.11
- tapable: 2.3.2
+ tapable: 2.3.3
enquirer@2.4.1:
dependencies:
@@ -3945,7 +3955,7 @@ snapshots:
eslint-compat-utils@0.5.1(eslint@10.0.0):
dependencies:
eslint: 10.0.0
- semver: 7.7.4
+ semver: 7.8.1
eslint-config-prettier@9.1.0(eslint@10.0.0):
dependencies:
@@ -3965,14 +3975,14 @@ snapshots:
eslint-plugin-n@17.24.0(eslint@10.0.0)(typescript@5.5.4):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0)
- enhanced-resolve: 5.20.1
+ enhanced-resolve: 5.22.1
eslint: 10.0.0
eslint-plugin-es-x: 7.8.0(eslint@10.0.0)
- get-tsconfig: 4.13.7
+ get-tsconfig: 4.14.0
globals: 15.15.0
globrex: 0.1.2
ignore: 5.3.2
- semver: 7.7.4
+ semver: 7.8.1
ts-declaration-location: 1.0.7(typescript@5.5.4)
transitivePeerDependencies:
- typescript
@@ -4176,7 +4186,7 @@ snapshots:
function-bind@1.1.2: {}
- get-tsconfig@4.13.7:
+ get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -4596,11 +4606,11 @@ snapshots:
pify@4.0.1: {}
- playwright-core@1.58.0: {}
+ playwright-core@1.60.0: {}
- playwright@1.58.0:
+ playwright@1.60.0:
dependencies:
- playwright-core: 1.58.0
+ playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
@@ -4742,6 +4752,8 @@ snapshots:
semver@7.7.4: {}
+ semver@7.8.1: {}
+
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
@@ -4829,7 +4841,7 @@ snapshots:
symbol-tree@3.2.4: {}
- tapable@2.3.2: {}
+ tapable@2.3.3: {}
term-size@2.2.1: {}