fix(formatter): avoid breaking after => when arrow body has JSDoc type cast (#20857)

closes https://github.com/oxc-project/oxc/issues/20180

---------

Co-authored-by: Dunqing <dengqing0821@gmail.com>
This commit is contained in:
babandDunqing authored and GitHub committed 2026-04-01 11:25:21 +08:00
1 parent f942b60daf
commit f9ef1bdb11
4 files changed
+161 -2

No files matched your search

@@ -451,6 +451,17 @@ impl<'a> Comments<'a> {
)
}
/// Checks if there is a type cast comment in the given range,
/// searching all comments regardless of print state.
pub fn has_type_cast_comment_in_range(&self, start: u32, end: u32) -> bool {
self.inner.iter().skip_while(|c| c.span.end < start).take_while(|c| c.span.end <= end).any(
|comment| {
self.source_text.next_non_whitespace_byte_is(comment.span.end, b'(')
&& self.is_type_cast_comment(comment)
},
)
}
/// Marks the given span as a type cast node.
pub fn mark_as_type_cast_node(&mut self, node: &impl GetSpan) {
self.type_cast_node_span = node.span();
@@ -626,10 +626,24 @@ fn can_group_arrow_function_expression_argument(
Expression::ArrowFunctionExpression(inner_arrow_function) => {
can_group_arrow_function_expression_argument(inner_arrow_function, true, f)
}
// In Prettier's Babel AST, a JSDoc type cast like `/** @type {X} */ (expr)` preserves
// the `ParenthesizedExpression` wrapper, so `arg.body` is not a CallExpression and
// `couldExpandArg` naturally returns false. In oxc's AST the parens are stripped, so we
// must explicitly check for type cast comments to prevent incorrect grouping.
// https://github.com/prettier/prettier/blob/812a4d0071270f61a7aa549d625b618be7e09d71/src/language-js/print/call-arguments.js#L232-L234
Expression::ChainExpression(chain) => {
matches!(chain.expression, ChainElement::CallExpression(_)) && !is_arrow_recursion
matches!(chain.expression, ChainElement::CallExpression(_))
&& !is_arrow_recursion
&& !f
.comments()
.has_type_cast_comment_in_range(arrow_function.span.start, expr.span().start)
}
Expression::CallExpression(_) | Expression::ConditionalExpression(_) => {
!is_arrow_recursion
&& !f
.comments()
.has_type_cast_comment_in_range(arrow_function.span.start, expr.span().start)
}
Expression::CallExpression(_) | Expression::ConditionalExpression(_) => !is_arrow_recursion,
_ => false,
})
}
@@ -0,0 +1,21 @@
items.map((child) => /** @type {SomeType} */ (visit(child)));
transform_body(first_argument, second_argument, (node) => /** @type {Node} */ (context.visit(node)));
// #20180 Arrow function body breaks after => when JSDoc type cast is in body
const longer_variable = items_with_longer_name.map((child) => /** @type {SomeTypeThatIsLonger} */ (visit(child)));
const body = transform_body(state.analysis.instance_body, b.id("$.run"), (node) => /** @type {Node} */ (context.visit(node)));
// Edge case: very long type cast that doesn't fit even after expanding arguments
const x = items_with_longer_name.map((child) => /** @type {SomeVeryVeryVeryVeryVeryVeryVeryLongType} */ (visit(child)));
// No false positive: type cast earlier in file should not affect unrelated arrow
const a = /** @type {X} */ (foo());
const result = items_with_longer_name.map((child) => bar_without_typecast(child));
// @satisfies type cast
const z = items_with_longer_name.map((child) => /** @satisfies {SomeTypeThatIsLonger} */ (visit(child)));
// Optional chaining with type cast
const w = items_with_longer_name.map((child) => /** @type {SomeTypeThatIsLonger} */ (child?.visit()));
@@ -0,0 +1,113 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
items.map((child) => /** @type {SomeType} */ (visit(child)));
transform_body(first_argument, second_argument, (node) => /** @type {Node} */ (context.visit(node)));
// #20180 Arrow function body breaks after => when JSDoc type cast is in body
const longer_variable = items_with_longer_name.map((child) => /** @type {SomeTypeThatIsLonger} */ (visit(child)));
const body = transform_body(state.analysis.instance_body, b.id("$.run"), (node) => /** @type {Node} */ (context.visit(node)));
// Edge case: very long type cast that doesn't fit even after expanding arguments
const x = items_with_longer_name.map((child) => /** @type {SomeVeryVeryVeryVeryVeryVeryVeryLongType} */ (visit(child)));
// No false positive: type cast earlier in file should not affect unrelated arrow
const a = /** @type {X} */ (foo());
const result = items_with_longer_name.map((child) => bar_without_typecast(child));
// @satisfies type cast
const z = items_with_longer_name.map((child) => /** @satisfies {SomeTypeThatIsLonger} */ (visit(child)));
// Optional chaining with type cast
const w = items_with_longer_name.map((child) => /** @type {SomeTypeThatIsLonger} */ (child?.visit()));
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
items.map((child) => /** @type {SomeType} */ (visit(child)));
transform_body(
first_argument,
second_argument,
(node) => /** @type {Node} */ (context.visit(node)),
);
// #20180 Arrow function body breaks after => when JSDoc type cast is in body
const longer_variable = items_with_longer_name.map(
(child) => /** @type {SomeTypeThatIsLonger} */ (visit(child)),
);
const body = transform_body(
state.analysis.instance_body,
b.id("$.run"),
(node) => /** @type {Node} */ (context.visit(node)),
);
// Edge case: very long type cast that doesn't fit even after expanding arguments
const x = items_with_longer_name.map(
(child) =>
/** @type {SomeVeryVeryVeryVeryVeryVeryVeryLongType} */ (visit(child)),
);
// No false positive: type cast earlier in file should not affect unrelated arrow
const a = /** @type {X} */ (foo());
const result = items_with_longer_name.map((child) =>
bar_without_typecast(child),
);
// @satisfies type cast
const z = items_with_longer_name.map(
(child) => /** @satisfies {SomeTypeThatIsLonger} */ (visit(child)),
);
// Optional chaining with type cast
const w = items_with_longer_name.map(
(child) => /** @type {SomeTypeThatIsLonger} */ (child?.visit()),
);
-------------------
{ printWidth: 100 }
-------------------
items.map((child) => /** @type {SomeType} */ (visit(child)));
transform_body(
first_argument,
second_argument,
(node) => /** @type {Node} */ (context.visit(node)),
);
// #20180 Arrow function body breaks after => when JSDoc type cast is in body
const longer_variable = items_with_longer_name.map(
(child) => /** @type {SomeTypeThatIsLonger} */ (visit(child)),
);
const body = transform_body(
state.analysis.instance_body,
b.id("$.run"),
(node) => /** @type {Node} */ (context.visit(node)),
);
// Edge case: very long type cast that doesn't fit even after expanding arguments
const x = items_with_longer_name.map(
(child) => /** @type {SomeVeryVeryVeryVeryVeryVeryVeryLongType} */ (visit(child)),
);
// No false positive: type cast earlier in file should not affect unrelated arrow
const a = /** @type {X} */ (foo());
const result = items_with_longer_name.map((child) => bar_without_typecast(child));
// @satisfies type cast
const z = items_with_longer_name.map(
(child) => /** @satisfies {SomeTypeThatIsLonger} */ (visit(child)),
);
// Optional chaining with type cast
const w = items_with_longer_name.map(
(child) => /** @type {SomeTypeThatIsLonger} */ (child?.visit()),
);
===================== End =====================