Skip to content

Commit 32b55ea

Browse files
breaking: warn on quotes single-expression attributes in runes mode (#12479)
* parse `foo={bar}` attribute as `value: { type: 'ExpressionTag', .. }` (i.e. don't wrap in an array) * warn on quoted single-expression-attributes * breaking: warn on quotes single-expression attributes in runes mode In a future version, that will mean things are getting stringified, which is a departure from how things work today, therefore a warning first. Related to #7925 * Update .changeset/plenty-items-build.md * Apply suggestions from code review * missed a spot --------- Co-authored-by: Rich Harris <[email protected]>
1 parent b88e667 commit 32b55ea

File tree

20 files changed

+320
-112
lines changed

20 files changed

+320
-112
lines changed

.changeset/plenty-items-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
breaking: warn on quoted single-expression attributes in runes mode

packages/svelte/messages/compile-warnings/template.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
> '%wrong%' is not a valid HTML attribute. Did you mean '%right%'?
1616
17+
## attribute_quoted
18+
19+
> Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes
20+
1721
## bind_invalid_each_rest
1822

1923
> The rest operator (...) will create a new object and binding '%name%' with the original object will not work

packages/svelte/src/compiler/legacy.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,34 @@ export function convert(source, ast) {
411411
)
412412
};
413413
},
414+
Attribute(node, { visit, next, path }) {
415+
if (node.value !== true && !Array.isArray(node.value)) {
416+
path.push(node);
417+
const value = /** @type {Legacy.LegacyAttribute['value']} */ ([visit(node.value)]);
418+
path.pop();
419+
420+
return {
421+
...node,
422+
value
423+
};
424+
} else {
425+
return next();
426+
}
427+
},
428+
StyleDirective(node, { visit, next, path }) {
429+
if (node.value !== true && !Array.isArray(node.value)) {
430+
path.push(node);
431+
const value = /** @type {Legacy.LegacyStyleDirective['value']} */ ([visit(node.value)]);
432+
path.pop();
433+
434+
return {
435+
...node,
436+
value
437+
};
438+
} else {
439+
return next();
440+
}
441+
},
414442
SpreadAttribute(node) {
415443
return { ...node, type: 'Spread' };
416444
},

packages/svelte/src/compiler/migrate/index.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -532,11 +532,13 @@ const template = {
532532
if (attr.name === 'name') {
533533
slot_name = /** @type {any} */ (attr.value)[0].data;
534534
} else {
535+
const attr_value =
536+
attr.value === true || Array.isArray(attr.value) ? attr.value : [attr.value];
535537
const value =
536-
attr.value !== true
538+
attr_value !== true
537539
? state.str.original.substring(
538-
attr.value[0].start,
539-
attr.value[attr.value.length - 1].end
540+
attr_value[0].start,
541+
attr_value[attr_value.length - 1].end
540542
)
541543
: 'true';
542544
slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `;

packages/svelte/src/compiler/phases/1-parse/read/options.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ export default function read_options(node) {
4141
case 'customElement': {
4242
/** @type {SvelteOptions['customElement']} */
4343
const ce = { tag: '' };
44+
const { value: v } = attribute;
45+
const value = v === true || Array.isArray(v) ? v : [v];
4446

45-
const { value } = attribute;
4647
if (value === true) {
4748
e.svelte_options_invalid_customelement(attribute);
4849
} else if (value[0].type === 'Text') {
@@ -199,7 +200,11 @@ export default function read_options(node) {
199200
*/
200201
function get_static_value(attribute) {
201202
const { value } = attribute;
202-
const chunk = value[0];
203+
204+
if (value === true) return true;
205+
206+
const chunk = Array.isArray(value) ? value[0] : value;
207+
203208
if (!chunk) return true;
204209
if (value.length > 1) {
205210
return null;
@@ -208,6 +213,7 @@ function get_static_value(attribute) {
208213
if (chunk.expression.type !== 'Literal') {
209214
return null;
210215
}
216+
211217
return chunk.expression.value;
212218
}
213219

packages/svelte/src/compiler/phases/1-parse/state/element.js

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as e from '../../../errors.js';
99
import * as w from '../../../warnings.js';
1010
import { create_fragment } from '../utils/create.js';
1111
import { create_attribute } from '../../nodes.js';
12+
import { get_attribute_expression, is_expression_attribute } from '../../../utils/ast.js';
1213

1314
// eslint-disable-next-line no-useless-escape
1415
const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
@@ -241,15 +242,11 @@ export default function element(parser) {
241242
}
242243

243244
const definition = /** @type {Compiler.Attribute} */ (element.attributes.splice(index, 1)[0]);
244-
if (
245-
definition.value === true ||
246-
definition.value.length !== 1 ||
247-
definition.value[0].type === 'Text'
248-
) {
245+
if (!is_expression_attribute(definition)) {
249246
e.svelte_component_invalid_this(definition.start);
250247
}
251248

252-
element.expression = definition.value[0].expression;
249+
element.expression = get_attribute_expression(definition);
253250
}
254251

255252
if (element.type === 'SvelteElement') {
@@ -267,15 +264,16 @@ export default function element(parser) {
267264
e.svelte_element_missing_this(definition);
268265
}
269266

270-
const chunk = definition.value[0];
271-
272-
if (definition.value.length !== 1 || chunk.type !== 'ExpressionTag') {
267+
if (!is_expression_attribute(definition)) {
273268
w.svelte_element_invalid_this(definition);
274269

275270
// note that this is wrong, in the case of e.g. `this="h{n}"` — it will result in `<h>`.
276271
// it would be much better to just error here, but we are preserving the existing buggy
277272
// Svelte 4 behaviour out of an overabundance of caution regarding breaking changes.
278273
// TODO in 6.0, error
274+
const chunk = /** @type {Array<Compiler.ExpressionTag | Compiler.Text>} */ (
275+
definition.value
276+
)[0];
279277
element.tag =
280278
chunk.type === 'Text'
281279
? {
@@ -287,7 +285,7 @@ export default function element(parser) {
287285
}
288286
: chunk.expression;
289287
} else {
290-
element.tag = chunk.expression;
288+
element.tag = get_attribute_expression(definition);
291289
}
292290
}
293291

@@ -543,7 +541,7 @@ function read_attribute(parser) {
543541
}
544542
};
545543

546-
return create_attribute(name, start, parser.index, [expression]);
544+
return create_attribute(name, start, parser.index, expression);
547545
}
548546
}
549547

@@ -557,7 +555,7 @@ function read_attribute(parser) {
557555
const colon_index = name.indexOf(':');
558556
const type = colon_index !== -1 && get_directive_type(name.slice(0, colon_index));
559557

560-
/** @type {true | Array<Compiler.Text | Compiler.ExpressionTag>} */
558+
/** @type {true | Compiler.ExpressionTag | Array<Compiler.Text | Compiler.ExpressionTag>} */
561559
let value = true;
562560
if (parser.eat('=')) {
563561
parser.allow_whitespace();
@@ -589,7 +587,9 @@ function read_attribute(parser) {
589587
};
590588
}
591589

592-
const first_value = value === true ? undefined : value[0];
590+
const first_value = value === true ? undefined : Array.isArray(value) ? value[0] : value;
591+
592+
/** @type {import('estree').Expression | null} */
593593
let expression = null;
594594

595595
if (first_value) {
@@ -598,6 +598,8 @@ function read_attribute(parser) {
598598
if (attribute_contains_text) {
599599
e.directive_invalid_value(/** @type {number} */ (first_value.start));
600600
} else {
601+
// TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`,
602+
// which means stringified value, which isn't allowed for some directives?
601603
expression = first_value.expression;
602604
}
603605
}
@@ -662,6 +664,7 @@ function get_directive_type(name) {
662664

663665
/**
664666
* @param {Parser} parser
667+
* @return {Compiler.ExpressionTag | Array<Compiler.ExpressionTag | Compiler.Text>}
665668
*/
666669
function read_attribute_value(parser) {
667670
const quote_mark = parser.eat("'") ? "'" : parser.eat('"') ? '"' : null;
@@ -678,6 +681,7 @@ function read_attribute_value(parser) {
678681
];
679682
}
680683

684+
/** @type {Array<Compiler.ExpressionTag | Compiler.Text>} */
681685
let value;
682686
try {
683687
value = read_sequence(
@@ -708,7 +712,12 @@ function read_attribute_value(parser) {
708712
}
709713

710714
if (quote_mark) parser.index += 1;
711-
return value;
715+
716+
if (quote_mark || value.length > 1 || value[0].type === 'Text') {
717+
return value;
718+
} else {
719+
return value[0];
720+
}
712721
}
713722

714723
/**

packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { walk } from 'zimmerframe';
44
import { get_possible_values } from './utils.js';
55
import { regex_ends_with_whitespace, regex_starts_with_whitespace } from '../../patterns.js';
6+
import { get_attribute_chunks, is_text_attribute } from '../../../utils/ast.js';
67

78
/**
89
* @typedef {{
@@ -444,14 +445,11 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
444445
if (attribute.value === true) return operator === null;
445446
if (expected_value === null) return true;
446447

447-
const chunks = attribute.value;
448-
if (chunks.length === 1) {
449-
const value = chunks[0];
450-
if (value.type === 'Text') {
451-
return test_attribute(operator, expected_value, case_insensitive, value.data);
452-
}
448+
if (is_text_attribute(attribute)) {
449+
return test_attribute(operator, expected_value, case_insensitive, attribute.value[0].data);
453450
}
454451

452+
const chunks = get_attribute_chunks(attribute.value);
455453
const possible_values = new Set();
456454

457455
/** @type {string[]} */

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
is_event_attribute,
1010
is_text_attribute,
1111
object,
12-
unwrap_optional
12+
unwrap_optional,
13+
get_attribute_expression,
14+
get_attribute_chunks
1315
} from '../../utils/ast.js';
1416
import * as b from '../../utils/builders.js';
1517
import { MathMLElements, ReservedKeywords, Runes, SVGElements } from '../constants.js';
@@ -597,19 +599,24 @@ export function analyze_component(root, source, options) {
597599
}
598600

599601
if (class_attribute && class_attribute.value !== true) {
600-
const chunks = class_attribute.value;
601-
602-
if (chunks.length === 1 && chunks[0].type === 'Text') {
603-
chunks[0].data += ` ${analysis.css.hash}`;
602+
if (is_text_attribute(class_attribute)) {
603+
class_attribute.value[0].data += ` ${analysis.css.hash}`;
604604
} else {
605-
chunks.push({
605+
/** @type {import('#compiler').Text} */
606+
const css_text = {
606607
type: 'Text',
607608
data: ` ${analysis.css.hash}`,
608609
raw: ` ${analysis.css.hash}`,
609610
start: -1,
610611
end: -1,
611612
parent: null
612-
});
613+
};
614+
615+
if (Array.isArray(class_attribute.value)) {
616+
class_attribute.value.push(css_text);
617+
} else {
618+
class_attribute.value = [class_attribute.value, css_text];
619+
}
613620
}
614621
} else {
615622
element.attributes.push(
@@ -1171,7 +1178,7 @@ const common_visitors = {
11711178

11721179
context.next();
11731180

1174-
node.metadata.dynamic = node.value.some((chunk) => {
1181+
node.metadata.dynamic = get_attribute_chunks(node.value).some((chunk) => {
11751182
if (chunk.type !== 'ExpressionTag') {
11761183
return false;
11771184
}
@@ -1192,8 +1199,7 @@ const common_visitors = {
11921199
context.state.analysis.uses_event_attributes = true;
11931200
}
11941201

1195-
const expression = node.value[0].expression;
1196-
1202+
const expression = get_attribute_expression(node);
11971203
const delegated_event = get_delegated_event(node.name.slice(2), expression, context);
11981204

11991205
if (delegated_event !== null) {
@@ -1228,7 +1234,7 @@ const common_visitors = {
12281234
}
12291235
} else {
12301236
context.next();
1231-
node.metadata.dynamic = node.value.some(
1237+
node.metadata.dynamic = get_attribute_chunks(node.value).some(
12321238
(node) => node.type === 'ExpressionTag' && node.metadata.dynamic
12331239
);
12341240
}

packages/svelte/src/compiler/phases/2-analyze/validation.js

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import * as e from '../../errors.js';
88
import {
99
extract_identifiers,
10+
get_attribute_expression,
1011
get_parent,
1112
is_expression_attribute,
1213
is_text_attribute,
@@ -33,9 +34,26 @@ import { Scope, get_rune } from '../scope.js';
3334
import { merge } from '../visitors.js';
3435
import { a11y_validators } from './a11y.js';
3536

36-
/** @param {import('#compiler').Attribute} attribute */
37-
function validate_attribute(attribute) {
38-
if (attribute.value === true || attribute.value.length === 1) return;
37+
/**
38+
* @param {import('#compiler').Attribute} attribute
39+
* @param {import('#compiler').ElementLike} parent
40+
*/
41+
function validate_attribute(attribute, parent) {
42+
if (
43+
Array.isArray(attribute.value) &&
44+
attribute.value.length === 1 &&
45+
attribute.value[0].type === 'ExpressionTag' &&
46+
(parent.type === 'Component' ||
47+
parent.type === 'SvelteComponent' ||
48+
parent.type === 'SvelteSelf' ||
49+
(parent.type === 'RegularElement' && is_custom_element_node(parent)))
50+
) {
51+
w.attribute_quoted(attribute);
52+
}
53+
54+
if (attribute.value === true || !Array.isArray(attribute.value) || attribute.value.length === 1) {
55+
return;
56+
}
3957

4058
const is_quoted = attribute.value.at(-1)?.end !== attribute.end;
4159

@@ -69,10 +87,10 @@ function validate_component(node, context) {
6987

7088
if (attribute.type === 'Attribute') {
7189
if (context.state.analysis.runes) {
72-
validate_attribute(attribute);
90+
validate_attribute(attribute, node);
7391

7492
if (is_expression_attribute(attribute)) {
75-
const expression = attribute.value[0].expression;
93+
const expression = get_attribute_expression(attribute);
7694
if (expression.type === 'SequenceExpression') {
7795
let i = /** @type {number} */ (expression.start);
7896
while (--i > 0) {
@@ -122,10 +140,10 @@ function validate_element(node, context) {
122140
const is_expression = is_expression_attribute(attribute);
123141

124142
if (context.state.analysis.runes) {
125-
validate_attribute(attribute);
143+
validate_attribute(attribute, node);
126144

127145
if (is_expression) {
128-
const expression = attribute.value[0].expression;
146+
const expression = get_attribute_expression(attribute);
129147
if (expression.type === 'SequenceExpression') {
130148
let i = /** @type {number} */ (expression.start);
131149
while (--i > 0) {
@@ -146,7 +164,7 @@ function validate_element(node, context) {
146164
e.attribute_invalid_event_handler(attribute);
147165
}
148166

149-
const value = attribute.value[0].expression;
167+
const value = get_attribute_expression(attribute);
150168
if (
151169
value.type === 'Identifier' &&
152170
value.name === attribute.name &&

0 commit comments

Comments
 (0)