From 9d0edf720ef9ad8d08294119af5a3ff534a90596 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 17 Mar 2026 18:57:53 -0400 Subject: [PATCH 1/3] fix: better error for TS annotations in snippets without lang="ts" --- .changeset/gentle-dots-dream.md | 5 +++++ .../src/compiler/phases/1-parse/index.js | 5 +++-- .../src/compiler/phases/1-parse/state/tag.js | 20 +++++++++++++++---- .../snippet-ts-annotation-no-lang/_config.js | 9 +++++++++ .../snippet-ts-annotation-no-lang/main.svelte | 3 +++ 5 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 .changeset/gentle-dots-dream.md create mode 100644 packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/main.svelte diff --git a/.changeset/gentle-dots-dream.md b/.changeset/gentle-dots-dream.md new file mode 100644 index 0000000000..d3987705ca --- /dev/null +++ b/.changeset/gentle-dots-dream.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +Improve error message when TypeScript syntax is used in snippet parameters without `lang="ts"` diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 81adbbb555..5c2426fac6 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -177,10 +177,11 @@ export class Parser { /** * @param {any} err + * @param {string} [suffix] * @returns {never} */ - acorn_error(err) { - e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, '')); + acorn_error(err, suffix = '') { + e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, '') + suffix); } /** 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 d9518c726f..255e190619 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -390,16 +390,28 @@ function open(parser) { const prelude = parser.template.slice(0, params_start).replace(/\S/g, ' '); const params = parser.template.slice(params_start, parser.index); - let function_expression = matched - ? /** @type {ArrowFunctionExpression} */ ( + let function_expression; + + if (matched) { + try { + function_expression = /** @type {ArrowFunctionExpression} */ ( parse_expression_at( prelude + `${params} => {}`, parser.root.comments, parser.ts, params_start ) - ) - : { params: [] }; + ); + } catch (/** @type {any} */ err) { + const suffix = + !parser.ts && parser.template[err.pos] === ':' + ? ` (did you forget to add \`lang="ts"\`?)` + : ''; + parser.acorn_error(err, suffix); + } + } else { + function_expression = { params: [] }; + } parser.allow_whitespace(); parser.eat('}', true); diff --git a/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/_config.js b/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/_config.js new file mode 100644 index 0000000000..b56e835f96 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [18, 18] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/main.svelte b/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/main.svelte new file mode 100644 index 0000000000..514e077bbc --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/snippet-ts-annotation-no-lang/main.svelte @@ -0,0 +1,3 @@ +{#snippet bar(text: string)} + {text} +{/snippet} From 40e296656e57c816deaecebfda506d644af62f22 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 1 Apr 2026 09:28:04 -0400 Subject: [PATCH 2/3] fix: generalize lang="ts" hint to all template expression contexts Instead of only hinting on snippet params, detect TypeScript syntax across all expression sites: acorn_error catches colon annotations that make acorn fail, read_expression catches as/satisfies/!/: that acorn silently skips over. --- .../src/compiler/phases/1-parse/index.js | 12 +++++-- .../phases/1-parse/read/expression.js | 35 +++++++++++++++---- .../src/compiler/phases/1-parse/state/tag.js | 6 +--- .../ts-annotation-no-lang-as/_config.js | 9 +++++ .../ts-annotation-no-lang-as/main.svelte | 1 + .../_config.js | 9 +++++ .../main.svelte | 1 + .../ts-annotation-no-lang-colon/_config.js | 9 +++++ .../ts-annotation-no-lang-colon/main.svelte | 1 + .../ts-annotation-no-lang-non-null/_config.js | 9 +++++ .../main.svelte | 1 + .../_config.js | 9 +++++ .../main.svelte | 1 + 13 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/main.svelte create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/main.svelte diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 5c2426fac6..c37ffc3c8b 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -177,11 +177,17 @@ export class Parser { /** * @param {any} err - * @param {string} [suffix] * @returns {never} */ - acorn_error(err, suffix = '') { - e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, '') + suffix); + acorn_error(err) { + const message = err.message.replace(regex_position_indicator, ''); + + const hint = + !this.ts && typeof err.pos === 'number' && this.template[err.pos] === ':' + ? ` (did you forget to add \`lang="ts"\`?)` + : ''; + + e.js_parse_error(err.pos, message + hint); } /** diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 5d21f85792..afc74f8557 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -33,14 +33,19 @@ export function get_loose_identifier(parser, opening_token) { * @returns {Expression} */ export default function read_expression(parser, opening_token, disallow_loose) { + /** @type {Expression | undefined} */ + let node; + try { let comment_index = parser.root.comments.length; - const node = parse_expression_at( - parser.template, - parser.root.comments, - parser.ts, - parser.index + node = /** @type {Expression} */ ( + parse_expression_at( + parser.template, + parser.root.comments, + parser.ts, + parser.index + ) ); let num_parens = 0; @@ -76,8 +81,6 @@ export default function read_expression(parser, opening_token, disallow_loose) { } parser.index = index; - - return /** @type {Expression} */ (node); } catch (err) { // If we are in an each loop we need the error to be thrown in cases like // `as { y = z }` so we still throw and handle the error there @@ -90,4 +93,22 @@ export default function read_expression(parser, opening_token, disallow_loose) { parser.acorn_error(err); } + + if (!parser.ts) { + let j = parser.index; + while (j < parser.template.length && regex_whitespace.test(parser.template[j])) j++; + + const remaining = parser.template.slice(j); + + if ( + remaining[0] === ':' || + /^satisfies[\s(]/.test(remaining) || + (remaining[0] === '!' && remaining[1] !== '=') || + (/^as[\s(]/.test(remaining) && !disallow_loose) + ) { + e.js_parse_error(j, `Unexpected token (did you forget to add \`lang="ts"\`?)`); + } + } + + return node; } 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 255e190619..396512a228 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -403,11 +403,7 @@ function open(parser) { ) ); } catch (/** @type {any} */ err) { - const suffix = - !parser.ts && parser.template[err.pos] === ':' - ? ` (did you forget to add \`lang="ts"\`?)` - : ''; - parser.acorn_error(err, suffix); + parser.acorn_error(err); } } else { function_expression = { params: [] }; diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/_config.js b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/_config.js new file mode 100644 index 0000000000..a6eca38f13 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [3, 3] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/main.svelte b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/main.svelte new file mode 100644 index 0000000000..b04bd39332 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-as/main.svelte @@ -0,0 +1 @@ +{x as number} \ No newline at end of file diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/_config.js b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/_config.js new file mode 100644 index 0000000000..0ae7494b60 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [14, 14] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/main.svelte b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/main.svelte new file mode 100644 index 0000000000..86351fb664 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-attribute/main.svelte @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/_config.js b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/_config.js new file mode 100644 index 0000000000..fe9f0c2e62 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [2, 2] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/main.svelte b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/main.svelte new file mode 100644 index 0000000000..70c0f4c13b --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-colon/main.svelte @@ -0,0 +1 @@ +{x: number} \ No newline at end of file diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/_config.js b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/_config.js new file mode 100644 index 0000000000..fe9f0c2e62 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [2, 2] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/main.svelte b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/main.svelte new file mode 100644 index 0000000000..6c2f391078 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-non-null/main.svelte @@ -0,0 +1 @@ +{x!} \ No newline at end of file diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/_config.js b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/_config.js new file mode 100644 index 0000000000..a6eca38f13 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/_config.js @@ -0,0 +1,9 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'js_parse_error', + message: 'Unexpected token (did you forget to add `lang="ts"`?)', + position: [3, 3] + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/main.svelte b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/main.svelte new file mode 100644 index 0000000000..4417664603 --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/ts-annotation-no-lang-satisfies/main.svelte @@ -0,0 +1 @@ +{x satisfies Foo} \ No newline at end of file From 70393e8d0e469bb7300d3484c192b6c5f3f5a6ba Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 7 Apr 2026 19:32:32 -0400 Subject: [PATCH 3/3] fix: adapt lang="ts" hint to simplified parser architecture Resolve merge issues with #18075 and #18077: - Fix inverted typescript flag in handle_parse_error - Remove stale parser.acorn_error() call in tag.js - Move TS syntax check inside try block in read_expression --- .../src/compiler/phases/1-parse/acorn.js | 8 ++-- .../phases/1-parse/read/expression.js | 41 +++++++++---------- .../src/compiler/phases/1-parse/state/tag.js | 16 ++------ 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 755086678c..c9c9d184ac 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -61,7 +61,7 @@ export function parse(source, comments, typescript, is_script) { } catch (err) { // TODO the `return` in necessary for TS<7 due to a bug; otherwise // the `finally` block is regarded as unreachable - return handle_parse_error(err, source, typescript); + return handle_parse_error(err); } finally { if (is_script) { // @ts-expect-error @@ -102,15 +102,15 @@ const regex_position_indicator = / \(\d+:\d+\)$/; /** * @param {any} err - * @param {string} source - * @param {boolean} typescript + * @param {string} [source] + * @param {boolean} [typescript] * @returns {never} */ function handle_parse_error(err, source, typescript) { let message = /** @type {string} */ (err.message).replace(regex_position_indicator, ''); let pos = /** @type {number} */ (err.pos); - if (typescript && source[pos] === ':') { + if (!typescript && source && source[pos] === ':') { message += ` (did you forget to add \`lang="ts"\`?)`; } diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 6beff287c5..736639e1f4 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -33,11 +33,8 @@ export function get_loose_identifier(parser, opening_token) { * @returns {Expression} */ export default function read_expression(parser, opening_token, disallow_loose) { - /** @type {ReturnType} */ - let node; - try { - node = parse_expression_at(parser, parser.template, parser.index); + const node = parse_expression_at(parser, parser.template, parser.index); let index = /** @type {number} */ (node.end); @@ -45,6 +42,24 @@ export default function read_expression(parser, opening_token, disallow_loose) { if (last_comment && last_comment.end > index) index = last_comment.end; parser.index = index; + + if (!parser.ts) { + let j = parser.index; + while (j < parser.template.length && regex_whitespace.test(parser.template[j])) j++; + + const remaining = parser.template.slice(j); + + if ( + remaining[0] === ':' || + /^satisfies[\s(]/.test(remaining) || + (remaining[0] === '!' && remaining[1] !== '=') || + (/^as[\s(]/.test(remaining) && !disallow_loose) + ) { + e.js_parse_error(j, `Unexpected token (did you forget to add \`lang="ts"\`?)`); + } + } + + return /** @type {Expression} */ (remove_parens(node)); } catch (err) { // If we are in an each loop we need the error to be thrown in cases like // `as { y = z }` so we still throw and handle the error there @@ -57,22 +72,4 @@ export default function read_expression(parser, opening_token, disallow_loose) { throw err; } - - if (!parser.ts) { - let j = parser.index; - while (j < parser.template.length && regex_whitespace.test(parser.template[j])) j++; - - const remaining = parser.template.slice(j); - - if ( - remaining[0] === ':' || - /^satisfies[\s(]/.test(remaining) || - (remaining[0] === '!' && remaining[1] !== '=') || - (/^as[\s(]/.test(remaining) && !disallow_loose) - ) { - e.js_parse_error(j, `Unexpected token (did you forget to add \`lang="ts"\`?)`); - } - } - - return /** @type {Expression} */ (remove_parens(node)); } 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 568761806d..ff153128a5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -390,19 +390,11 @@ function open(parser) { const prelude = parser.template.slice(0, params_start).replace(/\S/g, ' '); const params = parser.template.slice(params_start, parser.index); - let function_expression; - - if (matched) { - try { - function_expression = /** @type {ArrowFunctionExpression} */ ( + let function_expression = matched + ? /** @type {ArrowFunctionExpression} */ ( parse_expression_at(parser, prelude + `${params} => {}`, params_start) - ); - } catch (/** @type {any} */ err) { - parser.acorn_error(err); - } - } else { - function_expression = { params: [] }; - } + ) + : { params: [] }; parser.allow_whitespace(); parser.eat('}', true);