Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/wet-dingos-spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@biomejs/biome": patch
---

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.

```diff
-<form .Field></form.Field>
+<form.Field></form.Field>
```
46 changes: 46 additions & 0 deletions crates/biome_cli/tests/cases/regression_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,49 @@ fn issue_9180_2() {
result,
));
}

/// Regression test for https://github.com/biomejs/biome/issues/9300
///
/// This issue affects Tanstack Form users who use `<form.Field>` as their default API.
/// In Biome 2.4.5, lowercase component member expressions like `<form.Field>` were
/// incorrectly formatted as `<form .Field>` (with an extra space before the dot),
/// which breaks the code.
///
/// The official Tanstack Form docs https://tanstack.com/form/latest/docs/framework/svelte/quick-start
///
/// This test ensures that lowercase component member expressions in Svelte and Astro
/// files are formatted correctly without adding extra spaces.
#[test]
fn issue_9300() {
let fs = MemoryFileSystem::default();
let mut console = BufferConsole::default();

let svelte_file = Utf8Path::new("form.svelte");
fs.insert(svelte_file.into(), "<form.Field></form.Field>".as_bytes());

let astro_file = Utf8Path::new("form.astro");
fs.insert(astro_file.into(), "<form.Field></form.Field>".as_bytes());

let (fs, result) = run_cli(
fs,
&mut console,
Args::from(
[
"check",
"--write",
svelte_file.as_str(),
astro_file.as_str(),
]
.as_slice(),
),
);
assert!(result.is_ok(), "run_cli returned {result:?}");

assert_cli_snapshot(SnapshotPayload::new(
module_path!(),
"issue_9300",
fs,
console,
result,
));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
source: crates/biome_cli/tests/snap_test.rs
expression: redactor(content)
---
## `form.astro`

```astro
<form.Field></form.Field>
```

## `form.svelte`

```svelte
<form.Field></form.Field>
```

# Emitted Messages

```block
Checked 2 files in <TIME>. No fixes applied.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
let form;
let foo;
let Data;
---
<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
source: crates/biome_formatter_test/src/snapshot_builder.rs
info: astro/lowercase-member.astro
---

# Input

```astro
---
let form;
let foo;
let Data;
---
<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />

```


=============================

# Outputs

## Output 1

-----
Indent style: Tab
Indent width: 2
Line ending: LF
Line width: 80
Attribute Position: Auto
Bracket same line: false
Whitespace sensitivity: css
Indent script and style: false
Self close void elements: never
Trailing newline: true
-----

```astro
---
let form;
let foo;
let Data;
---

<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />

```



## Unimplemented nodes/tokens

"let form;\nlet foo;\nlet Data;\n-" => 4..34
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
source: crates/biome_formatter_test/src/snapshot_builder.rs
info: svelte/lowercase-member.svelte
---

# Input

```svelte
<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />

```


=============================

# Outputs

## Output 1

-----
Indent style: Tab
Indent width: 2
Line ending: LF
Line width: 80
Attribute Position: Auto
Bracket same line: false
Whitespace sensitivity: css
Indent script and style: false
Self close void elements: never
Trailing newline: true
-----

```svelte
<form.Field></form.Field>
<form.Field attr="value" />
<Data.Client></Data.Client>
<foo.Bar.Baz />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />

```
10 changes: 7 additions & 3 deletions crates/biome_html_parser/src/lexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ impl<'src> HtmlLexer<'src> {
EXL => self.consume_byte(T![!]),
// Handle colons as separate tokens for Astro directives
COL => self.consume_byte(T![:]),
PRD => self.consume_byte(T![.]),
BEO if self.at_svelte_opening_block() => self.consume_svelte_opening_block(),
BEO => {
if self.at_opening_double_text_expression() {
Expand Down Expand Up @@ -276,12 +277,14 @@ impl<'src> HtmlLexer<'src> {
fn consume_token_inside_tag_svelte(&mut self, current: u8) -> HtmlSyntaxKind {
let dispatched = lookup_byte(current);

if dispatched == SLH {
match self.byte_at(1).map(lookup_byte) {
match dispatched {
SLH => match self.byte_at(1).map(lookup_byte) {
Some(SLH) => return self.consume_js_line_comment(),
Some(MUL) => return self.consume_js_block_comment(),
_ => {}
}
},
PRD => return self.consume_byte(T![.]),
_ => {}
}
self.consume_token_inside_tag(current)
}
Expand Down Expand Up @@ -1446,6 +1449,7 @@ impl<'src> ReLexer<'src> for HtmlLexer<'src> {
HtmlReLexContext::HtmlText => self.consume_html_text(current),
HtmlReLexContext::InsideTag => self.consume_token_inside_tag(current),
HtmlReLexContext::InsideTagAstro => self.consume_token_inside_tag_astro(current),
HtmlReLexContext::InsideTagSvelte => self.consume_token_inside_tag_svelte(current),
},
None => EOF,
};
Expand Down
42 changes: 29 additions & 13 deletions crates/biome_html_parser/src/syntax/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ fn inside_tag_context(p: &HtmlParser) -> HtmlLexContext {
HtmlLexContext::InsideTagWithDirectives { svelte: false }
} else if Svelte.is_supported(p) {
HtmlLexContext::InsideTagSvelte
} else if Astro.is_supported(p) {
HtmlLexContext::InsideTagAstro
} else {
HtmlLexContext::InsideTag
}
Expand Down Expand Up @@ -190,11 +192,14 @@ fn parse_any_tag_name(p: &mut HtmlParser) -> ParsedSyntax {
if !is_at_start_literal(p) {
return Absent;
}

let tag_text = p.cur_text();

// Step 1: Parse base name (either component or regular tag)
let name = if is_possible_component(p, tag_text) {
// Check if this could be a component or has member expression
let is_component = is_possible_component(p, tag_text);
let has_member_expression = !p.options().is_html() && p.nth_at(1, T![.]);

// Step 1: Parse base name
let name = if is_component || has_member_expression {
// Parse as component name - use component_name_context to allow `.` for member expressions
let m = p.start();
p.bump_with_context(HTML_LITERAL, component_name_context(p));
Expand All @@ -203,14 +208,18 @@ fn parse_any_tag_name(p: &mut HtmlParser) -> ParsedSyntax {
// Parse as regular HTML tag
parse_literal(p, HTML_TAG_NAME)
};

// Step 2: Extend with member access if present (using .map() pattern from JSX parser)
name.map(|mut name| {
while p.at(T![.]) {
let m = name.precede(p); // Create marker BEFORE already-parsed name
p.bump_with_context(T![.], component_name_context(p)); // Use component context for `.`
// Check kind BEFORE moving name with precede()
let is_lowercase_tag = name.kind(p) == HTML_TAG_NAME;

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

name = m.complete(p, HTML_MEMBER_NAME); // Wrap previous name
name = m.complete(p, HTML_MEMBER_NAME);
}
name
})
Expand All @@ -243,8 +252,15 @@ fn parse_element(p: &mut HtmlParser) -> ParsedSyntax {

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

if Astro.is_supported(p) {
p.re_lex(HtmlReLexContext::InsideTagAstro);
let context = inside_tag_context(p);
match context {
HtmlLexContext::InsideTagSvelte => {
p.re_lex(HtmlReLexContext::InsideTagSvelte);
}
HtmlLexContext::InsideTagAstro => {
p.re_lex(HtmlReLexContext::InsideTagAstro);
}
_ => {}
}

AttributeList.parse_list(p);
Expand Down Expand Up @@ -318,8 +334,8 @@ fn parse_closing_tag(p: &mut HtmlParser) -> ParsedSyntax {
return Absent;
}
let m = p.start();
p.bump_with_context(T![<], HtmlLexContext::InsideTag);
p.bump_with_context(T![/], HtmlLexContext::InsideTag);
p.bump_with_context(T![<], inside_tag_context(p));
p.bump_with_context(T![/], inside_tag_context(p));
let should_be_self_closing = VOID_ELEMENTS
.iter()
.any(|tag| tag.eq_ignore_ascii_case(p.cur_text()))
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_html_parser/src/token_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ pub(crate) enum HtmlReLexContext {
InsideTag,
/// Relex tokens as if the parser was inside a tag in an Astro file.
InsideTagAstro,
/// Relex tokens as if the parser was inside a tag in a Svelte file.
InsideTagSvelte,
}

pub(crate) type HtmlTokenSourceCheckpoint = TokenSourceCheckpoint<HtmlSyntaxKind>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
let Data;
---
<Data.Client></Data.Client>
<form.Field attr="value" />
<form.Field data-foo.bar="value" />
<foo.Bar attr.data-foo="value" />
Loading