fix(formatter): keep ; before in and instanceof class members (#28166)

Closes #28065

Prettier's `shouldPrintSemicolonAfterClassProperty` (and also biome) already has this rule, but our port missed it.

- Treat an `in` / `instanceof` key like a computed key in `needs_semicolon`, with the same modifier exclusions
- Check a string key by its value, since `"in"` may be printed unquoted
  - Prettier unquotes it and drops the `;`, producing invalid code
  - DIVERGENCES.md#string-binary-keyword-class-key
This commit is contained in:
leaysgur committed 2026-10-26 05:43:53 +00:00
1 parent 3a56e7c0c1
commit a5314a1c8a
7 files changed
+261 -9

No files matched your search

+29
View File
@@ -1050,3 +1050,32 @@ const --> comment
A closing HTML comment (`-->`, Annex B) is a comment only at the start of a line.
Prettier inlines it after `const` or `var`, producing code rejected by `node --check`, and misparses the `let` case as an expression statement.
We always print a line break before `-->`, which also splits a block comment preceding it on the same line, where Prettier's output stays valid.
## string-binary-keyword-class-key
- Why: semantics
- Pin: `tests/fixtures/js/semicolons/binary-keyword-class-key.js`, `tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts`
```js
// input (semi: false)
class A {
a = 1;
"in";
}
// ours
class A {
a = 1;
in
}
// prettier
class A {
a = 1
in
}
```
A class member named `in` or `instanceof` would continue the previous property's value as a binary operator, so the `;` before it is kept.
Prettier checks only an identifier key, but also prints the string key `"in"` unquoted, producing code rejected by `node --check`.
We check a string key by its value, which also keeps the `;` before a key that stays quoted (TypeScript, `quoteProps: "preserve"`), where Prettier's output stays valid.
+23 -4
View File
@@ -571,10 +571,15 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> {
let Some(next_element) = next_element else { return false };
// When the name starts with `[`, `in` or `instanceof`.
// NOTE: These only continue a previous property with a value (`x = a` + `in`, `x = a` + `[b]`),
// but kept regardless of it like Prettier, an unneeded `;` is harmless.
let starts_with_hazard = next_element.computed()
|| next_element.property_key().is_some_and(is_binary_keyword_key);
match next_element.as_ref() {
// When the name starts with the generator token or `[`
// Or the generator token
ClassElement::MethodDefinition(def) if !def.value.r#async => {
(def.computed
(starts_with_hazard
&& !(def.kind.is_accessor()
|| def.r#static
|| def.accessibility.is_some()
@@ -582,7 +587,7 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> {
|| def.value.generator
}
ClassElement::PropertyDefinition(def) => {
def.computed
starts_with_hazard
&& !(def.accessibility.is_some()
|| def.r#static
|| def.declare
@@ -590,7 +595,8 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> {
|| def.readonly)
}
ClassElement::AccessorProperty(def) => {
def.computed && !(def.accessibility.is_some() || def.r#static || def.r#override)
starts_with_hazard
&& !(def.accessibility.is_some() || def.r#static || def.r#override)
}
ClassElement::TSIndexSignature(_) => true,
_ => false,
@@ -598,6 +604,19 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> {
}
}
/// `in` or `instanceof` would continue the previous property's value as a binary operator.
/// A string key may be printed unquoted, so it is checked by its value.
/// See DIVERGENCES.md#string-binary-keyword-class-key
fn is_binary_keyword_key(key: &PropertyKey<'_>) -> bool {
match key {
PropertyKey::StaticIdentifier(ident) => matches!(ident.name.as_str(), "in" | "instanceof"),
PropertyKey::StringLiteral(literal) => {
literal.value == "in" || literal.value == "instanceof"
}
_ => false,
}
}
impl FormatClassElementWithSemicolon<'_, '_> {
/// Where the element's content ends and its `;` takes over, for the kinds that take one:
/// a property's value (the chain leaf, like an expression statement), type annotation or key,
@@ -0,0 +1,22 @@
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in;
b = 1;
instanceof() {}
c;
in;
d = 1;
"in";
e = 1;
"instanceof"() {}
}
class B {
a = 1;
static in;
b = 1;
get in() {}
c = 1;
async in() {}
}
@@ -0,0 +1,133 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in;
b = 1;
instanceof() {}
c;
in;
d = 1;
"in";
e = 1;
"instanceof"() {}
}
class B {
a = 1;
static in;
b = 1;
get in() {}
c = 1;
async in() {}
}
==================== Output ====================
------------------------------
{ printWidth: 80, semi: true }
------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in;
b = 1;
instanceof() {}
c;
in;
d = 1;
in;
e = 1;
instanceof() {}
}
class B {
a = 1;
static in;
b = 1;
get in() {}
c = 1;
async in() {}
}
-------------------------------
{ printWidth: 100, semi: true }
-------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in;
b = 1;
instanceof() {}
c;
in;
d = 1;
in;
e = 1;
instanceof() {}
}
class B {
a = 1;
static in;
b = 1;
get in() {}
c = 1;
async in() {}
}
-------------------------------
{ printWidth: 80, semi: false }
-------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in
b = 1;
instanceof() {}
c;
in
d = 1;
in
e = 1;
instanceof() {}
}
class B {
a = 1
static in
b = 1
get in() {}
c = 1
async in() {}
}
--------------------------------
{ printWidth: 100, semi: false }
--------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
in
b = 1;
instanceof() {}
c;
in
d = 1;
in
e = 1;
instanceof() {}
}
class B {
a = 1
static in
b = 1
get in() {}
c = 1
async in() {}
}
===================== End =====================
@@ -0,0 +1,5 @@
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number;
}
@@ -0,0 +1,48 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number;
}
==================== Output ====================
------------------------------
{ printWidth: 80, semi: true }
------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number;
}
-------------------------------
{ printWidth: 100, semi: true }
-------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number;
}
-------------------------------
{ printWidth: 80, semi: false }
-------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number
}
--------------------------------
{ printWidth: 100, semi: false }
--------------------------------
// DIVERGENCES.md#string-binary-keyword-class-key
class A {
a = 1;
"in": number
}
===================== End =====================
@@ -2,7 +2,7 @@ commit: a56fe4a4
formatter_babel Summary:
AST Parsed : 2280/2280 (100.00%)
Positive Passed: 2272/2280 (99.65%)
Positive Passed: 2274/2280 (99.74%)
Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/class-method/input.js
Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/class-private-method/input.js
@@ -15,7 +15,3 @@ Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basi
Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/sequence-expression/input.js
Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/es2022/class-properties/escaped-in-after-initializer/input.js
Cannot assign to this expression
Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typescript/class/escaped-in-after-initializer/input.ts
Cannot assign to this expression