Skip to content

Commit 86fbc70

Browse files
sepagiandyc3
andauthored
fix: lowercase component member expressions in Astro/Svelte (#9302)
Co-authored-by: Carson McManus <carson.mcmanus1@gmail.com>
1 parent 855b451 commit 86fbc70

File tree

14 files changed

+774
-16
lines changed

14 files changed

+774
-16
lines changed

.changeset/wet-dingos-spend.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#9300](https://github.com/biomejs/biome/issues/9300): Lowercase component member expressions like `<form.Field>` in Svelte and Astro files are now correctly formatted.
6+
7+
```diff
8+
-<form .Field></form.Field>
9+
+<form.Field></form.Field>
10+
```

crates/biome_cli/tests/cases/regression_tests.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,49 @@ fn issue_9180_2() {
6969
result,
7070
));
7171
}
72+
73+
/// Regression test for https://github.com/biomejs/biome/issues/9300
74+
///
75+
/// This issue affects Tanstack Form users who use `<form.Field>` as their default API.
76+
/// In Biome 2.4.5, lowercase component member expressions like `<form.Field>` were
77+
/// incorrectly formatted as `<form .Field>` (with an extra space before the dot),
78+
/// which breaks the code.
79+
///
80+
/// The official Tanstack Form docs https://tanstack.com/form/latest/docs/framework/svelte/quick-start
81+
///
82+
/// This test ensures that lowercase component member expressions in Svelte and Astro
83+
/// files are formatted correctly without adding extra spaces.
84+
#[test]
85+
fn issue_9300() {
86+
let fs = MemoryFileSystem::default();
87+
let mut console = BufferConsole::default();
88+
89+
let svelte_file = Utf8Path::new("form.svelte");
90+
fs.insert(svelte_file.into(), "<form.Field></form.Field>".as_bytes());
91+
92+
let astro_file = Utf8Path::new("form.astro");
93+
fs.insert(astro_file.into(), "<form.Field></form.Field>".as_bytes());
94+
95+
let (fs, result) = run_cli(
96+
fs,
97+
&mut console,
98+
Args::from(
99+
[
100+
"check",
101+
"--write",
102+
svelte_file.as_str(),
103+
astro_file.as_str(),
104+
]
105+
.as_slice(),
106+
),
107+
);
108+
assert!(result.is_ok(), "run_cli returned {result:?}");
109+
110+
assert_cli_snapshot(SnapshotPayload::new(
111+
module_path!(),
112+
"issue_9300",
113+
fs,
114+
console,
115+
result,
116+
));
117+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: crates/biome_cli/tests/snap_test.rs
3+
expression: redactor(content)
4+
---
5+
## `form.astro`
6+
7+
```astro
8+
<form.Field></form.Field>
9+
```
10+
11+
## `form.svelte`
12+
13+
```svelte
14+
<form.Field></form.Field>
15+
```
16+
17+
# Emitted Messages
18+
19+
```block
20+
Checked 2 files in <TIME>. No fixes applied.
21+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
let form;
3+
let foo;
4+
let Data;
5+
---
6+
<form.Field></form.Field>
7+
<form.Field attr="value" />
8+
<Data.Client></Data.Client>
9+
<foo.Bar.Baz />
10+
<form.Field data-foo.bar="value" />
11+
<foo.Bar attr.data-foo="value" />
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
info: astro/lowercase-member.astro
4+
---
5+
6+
# Input
7+
8+
```astro
9+
---
10+
let form;
11+
let foo;
12+
let Data;
13+
---
14+
<form.Field></form.Field>
15+
<form.Field attr="value" />
16+
<Data.Client></Data.Client>
17+
<foo.Bar.Baz />
18+
<form.Field data-foo.bar="value" />
19+
<foo.Bar attr.data-foo="value" />
20+
21+
```
22+
23+
24+
=============================
25+
26+
# Outputs
27+
28+
## Output 1
29+
30+
-----
31+
Indent style: Tab
32+
Indent width: 2
33+
Line ending: LF
34+
Line width: 80
35+
Attribute Position: Auto
36+
Bracket same line: false
37+
Whitespace sensitivity: css
38+
Indent script and style: false
39+
Self close void elements: never
40+
Trailing newline: true
41+
-----
42+
43+
```astro
44+
---
45+
let form;
46+
let foo;
47+
let Data;
48+
---
49+
50+
<form.Field></form.Field>
51+
<form.Field attr="value" />
52+
<Data.Client></Data.Client>
53+
<foo.Bar.Baz />
54+
<form.Field data-foo.bar="value" />
55+
<foo.Bar attr.data-foo="value" />
56+
57+
```
58+
59+
60+
61+
## Unimplemented nodes/tokens
62+
63+
"let form;\nlet foo;\nlet Data;\n-" => 4..34
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<form.Field></form.Field>
2+
<form.Field attr="value" />
3+
<Data.Client></Data.Client>
4+
<foo.Bar.Baz />
5+
<form.Field data-foo.bar="value" />
6+
<foo.Bar attr.data-foo="value" />
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
info: svelte/lowercase-member.svelte
4+
---
5+
6+
# Input
7+
8+
```svelte
9+
<form.Field></form.Field>
10+
<form.Field attr="value" />
11+
<Data.Client></Data.Client>
12+
<foo.Bar.Baz />
13+
<form.Field data-foo.bar="value" />
14+
<foo.Bar attr.data-foo="value" />
15+
16+
```
17+
18+
19+
=============================
20+
21+
# Outputs
22+
23+
## Output 1
24+
25+
-----
26+
Indent style: Tab
27+
Indent width: 2
28+
Line ending: LF
29+
Line width: 80
30+
Attribute Position: Auto
31+
Bracket same line: false
32+
Whitespace sensitivity: css
33+
Indent script and style: false
34+
Self close void elements: never
35+
Trailing newline: true
36+
-----
37+
38+
```svelte
39+
<form.Field></form.Field>
40+
<form.Field attr="value" />
41+
<Data.Client></Data.Client>
42+
<foo.Bar.Baz />
43+
<form.Field data-foo.bar="value" />
44+
<foo.Bar attr.data-foo="value" />
45+
46+
```

crates/biome_html_parser/src/lexer/mod.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ impl<'src> HtmlLexer<'src> {
135135
EXL => self.consume_byte(T![!]),
136136
// Handle colons as separate tokens for Astro directives
137137
COL => self.consume_byte(T![:]),
138+
PRD => self.consume_byte(T![.]),
138139
BEO if self.at_svelte_opening_block() => self.consume_svelte_opening_block(),
139140
BEO => {
140141
if self.at_opening_double_text_expression() {
@@ -276,12 +277,14 @@ impl<'src> HtmlLexer<'src> {
276277
fn consume_token_inside_tag_svelte(&mut self, current: u8) -> HtmlSyntaxKind {
277278
let dispatched = lookup_byte(current);
278279

279-
if dispatched == SLH {
280-
match self.byte_at(1).map(lookup_byte) {
280+
match dispatched {
281+
SLH => match self.byte_at(1).map(lookup_byte) {
281282
Some(SLH) => return self.consume_js_line_comment(),
282283
Some(MUL) => return self.consume_js_block_comment(),
283284
_ => {}
284-
}
285+
},
286+
PRD => return self.consume_byte(T![.]),
287+
_ => {}
285288
}
286289
self.consume_token_inside_tag(current)
287290
}
@@ -1446,6 +1449,7 @@ impl<'src> ReLexer<'src> for HtmlLexer<'src> {
14461449
HtmlReLexContext::HtmlText => self.consume_html_text(current),
14471450
HtmlReLexContext::InsideTag => self.consume_token_inside_tag(current),
14481451
HtmlReLexContext::InsideTagAstro => self.consume_token_inside_tag_astro(current),
1452+
HtmlReLexContext::InsideTagSvelte => self.consume_token_inside_tag_svelte(current),
14491453
},
14501454
None => EOF,
14511455
};

crates/biome_html_parser/src/syntax/mod.rs

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ fn inside_tag_context(p: &HtmlParser) -> HtmlLexContext {
153153
HtmlLexContext::InsideTagWithDirectives { svelte: false }
154154
} else if Svelte.is_supported(p) {
155155
HtmlLexContext::InsideTagSvelte
156+
} else if Astro.is_supported(p) {
157+
HtmlLexContext::InsideTagAstro
156158
} else {
157159
HtmlLexContext::InsideTag
158160
}
@@ -190,11 +192,14 @@ fn parse_any_tag_name(p: &mut HtmlParser) -> ParsedSyntax {
190192
if !is_at_start_literal(p) {
191193
return Absent;
192194
}
193-
194195
let tag_text = p.cur_text();
195196

196-
// Step 1: Parse base name (either component or regular tag)
197-
let name = if is_possible_component(p, tag_text) {
197+
// Check if this could be a component or has member expression
198+
let is_component = is_possible_component(p, tag_text);
199+
let has_member_expression = !p.options().is_html() && p.nth_at(1, T![.]);
200+
201+
// Step 1: Parse base name
202+
let name = if is_component || has_member_expression {
198203
// Parse as component name - use component_name_context to allow `.` for member expressions
199204
let m = p.start();
200205
p.bump_with_context(HTML_LITERAL, component_name_context(p));
@@ -203,14 +208,18 @@ fn parse_any_tag_name(p: &mut HtmlParser) -> ParsedSyntax {
203208
// Parse as regular HTML tag
204209
parse_literal(p, HTML_TAG_NAME)
205210
};
206-
207211
// Step 2: Extend with member access if present (using .map() pattern from JSX parser)
208212
name.map(|mut name| {
209-
while p.at(T![.]) {
210-
let m = name.precede(p); // Create marker BEFORE already-parsed name
211-
p.bump_with_context(T![.], component_name_context(p)); // Use component context for `.`
213+
// Check kind BEFORE moving name with precede()
214+
let is_lowercase_tag = name.kind(p) == HTML_TAG_NAME;
212215

213-
// Parse member name - must use component_name_context to maintain `.` lexing
216+
while p.at(T![.]) {
217+
// Convert BEFORE precede takes ownership of name
218+
if is_lowercase_tag {
219+
name.change_kind(p, HTML_COMPONENT_NAME);
220+
}
221+
let m = name.precede(p);
222+
p.bump_with_context(T![.], component_name_context(p));
214223
if is_at_start_literal(p) {
215224
let member_m = p.start();
216225
p.bump_with_context(HTML_LITERAL, component_name_context(p));
@@ -219,7 +228,7 @@ fn parse_any_tag_name(p: &mut HtmlParser) -> ParsedSyntax {
219228
p.error(expected_element_name(p, p.cur_range()));
220229
}
221230

222-
name = m.complete(p, HTML_MEMBER_NAME); // Wrap previous name
231+
name = m.complete(p, HTML_MEMBER_NAME);
223232
}
224233
name
225234
})
@@ -243,8 +252,15 @@ fn parse_element(p: &mut HtmlParser) -> ParsedSyntax {
243252

244253
parse_any_tag_name(p).or_add_diagnostic(p, expected_element_name);
245254

246-
if Astro.is_supported(p) {
247-
p.re_lex(HtmlReLexContext::InsideTagAstro);
255+
let context = inside_tag_context(p);
256+
match context {
257+
HtmlLexContext::InsideTagSvelte => {
258+
p.re_lex(HtmlReLexContext::InsideTagSvelte);
259+
}
260+
HtmlLexContext::InsideTagAstro => {
261+
p.re_lex(HtmlReLexContext::InsideTagAstro);
262+
}
263+
_ => {}
248264
}
249265

250266
AttributeList.parse_list(p);
@@ -318,8 +334,8 @@ fn parse_closing_tag(p: &mut HtmlParser) -> ParsedSyntax {
318334
return Absent;
319335
}
320336
let m = p.start();
321-
p.bump_with_context(T![<], HtmlLexContext::InsideTag);
322-
p.bump_with_context(T![/], HtmlLexContext::InsideTag);
337+
p.bump_with_context(T![<], inside_tag_context(p));
338+
p.bump_with_context(T![/], inside_tag_context(p));
323339
let should_be_self_closing = VOID_ELEMENTS
324340
.iter()
325341
.any(|tag| tag.eq_ignore_ascii_case(p.cur_text()))

crates/biome_html_parser/src/token_source.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ pub(crate) enum HtmlReLexContext {
156156
InsideTag,
157157
/// Relex tokens as if the parser was inside a tag in an Astro file.
158158
InsideTagAstro,
159+
/// Relex tokens as if the parser was inside a tag in a Svelte file.
160+
InsideTagSvelte,
159161
}
160162

161163
pub(crate) type HtmlTokenSourceCheckpoint = TokenSourceCheckpoint<HtmlSyntaxKind>;

0 commit comments

Comments
 (0)