diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index a09eafb3f..9cb10e404 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -125,6 +125,7 @@ mod jest {
mod react {
pub mod jsx_key;
+ pub mod jsx_no_useless_fragment;
pub mod no_children_prop;
}
@@ -248,8 +249,9 @@ oxc_macros::declare_all_lint_rules! {
unicorn::no_thenable,
unicorn::throw_new_error,
unicorn::prefer_array_flat_map,
- react::jsx_key,
react::no_children_prop,
+ react::jsx_key,
+ react::jsx_no_useless_fragment,
import::named,
import::no_cycle,
import::no_self_import,
diff --git a/crates/oxc_linter/src/rules/react/jsx_no_useless_fragment.rs b/crates/oxc_linter/src/rules/react/jsx_no_useless_fragment.rs
new file mode 100644
index 000000000..2678e4403
--- /dev/null
+++ b/crates/oxc_linter/src/rules/react/jsx_no_useless_fragment.rs
@@ -0,0 +1,299 @@
+use oxc_ast::{
+ ast::{
+ Expression, JSXAttributeItem, JSXAttributeName, JSXChild, JSXElement, JSXElementName,
+ JSXExpression, JSXFragment, JSXMemberExpressionObject, JSXOpeningElement,
+ },
+ AstKind,
+};
+use oxc_diagnostics::{
+ miette::{self, Diagnostic},
+ thiserror::Error,
+};
+use oxc_macros::declare_oxc_lint;
+use oxc_semantic::AstNodeId;
+use oxc_span::Span;
+
+use crate::{context::LintContext, rule::Rule, AstNode};
+
+#[derive(Debug, Error, Diagnostic)]
+enum JsxNoUselessFragmentDiagnostic {
+ #[error("eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.")]
+ #[diagnostic(severity(warning))]
+ NeedsMoreChildren(#[label] Span),
+ #[error("eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.")]
+ #[diagnostic(severity(warning))]
+ ChildOfHtmlElement(#[label] Span),
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct JsxNoUselessFragment {
+ /// Allow fragments with a single expression child.
+ pub allow_expressions: bool,
+}
+
+declare_oxc_lint!(
+ /// ### What it does
+ ///
+ /// Disallow unnecessary fragments.
+ ///
+ /// ### Why is this bad?
+ ///
+ /// Fragments are a useful tool when you need to group multiple children without adding a node to the DOM tree. However, sometimes you might end up with a fragment with a single child. When this child is an element, string, or expression, it's not necessary to use a fragment.
+ ///
+ /// ### Example
+ /// ```javascript
+ /// // Bad
+ /// <>foo>
+ ///
<>foo>
+ ///
+ /// // Good
+ /// <>foo >
+ /// foo
+ /// ```
+ JsxNoUselessFragment,
+ correctness
+);
+
+impl Rule for JsxNoUselessFragment {
+ fn from_configuration(value: serde_json::Value) -> Self {
+ let allow_expressions =
+ value.get("allowExpressions").and_then(serde_json::Value::as_bool).unwrap_or(false);
+
+ Self { allow_expressions }
+ }
+
+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
+ match node.kind() {
+ AstKind::JSXElement(jsx_elem) => {
+ if !is_jsx_fragment(&jsx_elem.opening_element) {
+ return;
+ }
+ self.check_element(node, jsx_elem, ctx);
+ }
+ AstKind::JSXFragment(jsx_elem) => {
+ self.check_fragment(node, jsx_elem, ctx);
+ }
+ _ => {}
+ }
+ }
+}
+
+impl JsxNoUselessFragment {
+ fn check_element(&self, node: &AstNode, elem: &JSXElement, ctx: &LintContext) {
+ if jsx_elem_has_key_attr(elem) {
+ return;
+ }
+
+ if has_less_than_two_children(&elem.children)
+ && !is_fragment_with_only_text_and_is_not_child(node.id(), &elem.children, ctx)
+ && !(self.allow_expressions && is_fragment_with_single_expression(&elem.children))
+ {
+ ctx.diagnostic(JsxNoUselessFragmentDiagnostic::NeedsMoreChildren(elem.span));
+ }
+
+ if is_child_of_html_element(node, ctx) {
+ ctx.diagnostic(JsxNoUselessFragmentDiagnostic::ChildOfHtmlElement(elem.span));
+ }
+ }
+ fn check_fragment(&self, node: &AstNode, elem: &JSXFragment, ctx: &LintContext) {
+ if has_less_than_two_children(&elem.children)
+ && !is_fragment_with_only_text_and_is_not_child(node.id(), &elem.children, ctx)
+ && !(self.allow_expressions && is_fragment_with_single_expression(&elem.children))
+ {
+ ctx.diagnostic(JsxNoUselessFragmentDiagnostic::NeedsMoreChildren(elem.span));
+ }
+
+ if is_child_of_html_element(node, ctx) {
+ ctx.diagnostic(JsxNoUselessFragmentDiagnostic::ChildOfHtmlElement(elem.span));
+ }
+ }
+}
+
+fn jsx_elem_has_key_attr(elem: &JSXElement) -> bool {
+ elem.opening_element.attributes.iter().any(|attr| {
+ let JSXAttributeItem::Attribute(attr) = attr else { return false };
+
+ let JSXAttributeName::Identifier(ident) = &attr.name else { return false };
+
+ ident.name == "key"
+ })
+}
+
+fn is_fragment_with_single_expression(children: &oxc_allocator::Vec<'_, JSXChild<'_>>) -> bool {
+ let children = children.iter().filter(|v| is_padding_spaces(v)).collect::>();
+
+ children.len() == 1 && matches!(children[0], JSXChild::ExpressionContainer(_))
+}
+
+fn is_padding_spaces(v: &JSXChild<'_>) -> bool {
+ if let JSXChild::Text(v) = v {
+ return !(v.value.trim().is_empty() && v.value.contains('\n'));
+ }
+
+ true
+}
+
+fn is_child_of_html_element(node: &AstNode, ctx: &LintContext) -> bool {
+ if let Some(AstKind::JSXElement(elem)) = ctx.nodes().parent_kind(node.id()) {
+ if is_html_element(&elem.opening_element.name) {
+ return true;
+ }
+ }
+
+ false
+}
+
+fn is_html_element(elem_name: &JSXElementName) -> bool {
+ let JSXElementName::Identifier(ident) = elem_name else { return false };
+
+ ident.name.starts_with(char::is_lowercase)
+}
+
+fn is_jsx_fragment(elem: &JSXOpeningElement) -> bool {
+ match &elem.name {
+ JSXElementName::Identifier(ident) => ident.name.as_str() == "Fragment",
+ JSXElementName::MemberExpression(mem_expr) => {
+ if mem_expr.property.name.as_str() != "Fragment" {
+ return false;
+ }
+
+ let JSXMemberExpressionObject::Identifier(ident) = &mem_expr.object else {
+ return false;
+ };
+
+ return ident.name.as_str() == "React";
+ }
+ JSXElementName::NamespacedName(_) => false,
+ }
+}
+
+fn has_less_than_two_children(children: &oxc_allocator::Vec<'_, JSXChild<'_>>) -> bool {
+ let non_padding_children = children.iter().filter(|v| is_padding_spaces(v)).collect::>();
+
+ if non_padding_children.len() < 2 {
+ return !non_padding_children.iter().any(|v| {
+ if let JSXChild::ExpressionContainer(v) = v {
+ if let JSXExpression::Expression(Expression::CallExpression(_)) = v.expression {
+ return true;
+ }
+ return false;
+ }
+
+ false
+ });
+ }
+ false
+}
+
+fn is_fragment_with_only_text_and_is_not_child<'a>(
+ id: AstNodeId,
+ node: &oxc_allocator::Vec<'a, JSXChild<'a>>,
+ ctx: &LintContext,
+) -> bool {
+ if node.len() != 1 {
+ return false;
+ }
+
+ if let Some(JSXChild::Text(_)) = node.get(0) {
+ let Some(parent) = ctx.nodes().parent_kind(id) else { return false };
+ return !matches!(parent, AstKind::JSXElement(_) | AstKind::JSXFragment(_));
+ }
+
+ false
+}
+
+#[test]
+fn test() {
+ use crate::tester::Tester;
+ use serde_json::json;
+
+ let pass = vec![
+ (r#"<>>"#, None),
+ (r#"<>foo>"#, None),
+ (r#"<> >"#, None),
+ (r#"<>{"moo"} >"#, None),
+ (r#""#, None),
+ (r#""#, None),
+ (r#""#, None),
+ (r#"<>>"#, None),
+ (r#"{"a"}{"b"}>} />"#, None),
+ (r#"
{item.value}"#, None),
+ (r#"
eeee ee eeeeeee eeeeeeee>} />"#, None),
+ (r#"<>{foos.map(foo => foo)}>"#, None),
+ (r#"<>{moo}>"#, Some(json!({ "allowExpressions": true }))),
+ (
+ r#"
+ <>
+ {moo}
+ >
+ "#,
+ Some(json!({ "allowExpressions": true })),
+ ),
+ ];
+
+ let fail = vec![
+ (r#"<>>"#, None),
+ (r#"<>{}>"#, None),
+ (r#"moo<>foo>
"#, None),
+ (r#"<>{meow}>"#, None),
+ (r#"<>{meow}>
"#, None),
+ (r#"<>>"#, None),
+ (
+ r#"
+ <>
+
+ >
+ "#,
+ None,
+ ),
+ (r#""#, None),
+ (
+ r#"
+
+
+
+ "#,
+ None,
+ ),
+ (r#"<>foo>"#, None),
+ (r#"<>foo>
"#, None),
+ (r#"<>{"a"}{"b"}>
"#, None),
+ (r#"<>{"a"}{"b"}>
"#, None),
+ (
+ r#"
+ "#,
+ None,
+ ),
+ (r#"{"a"}{"b"}
"#, None),
+ (
+ r#"
+
+ git<>
+ hub.
+ >
+
+ git<> hub>
+
+ "#,
+ None,
+ ),
+ (r#"a <>{""}{""}> a
"#, None),
+ (
+ r#"
+ const Comp = () => (
+
+
+
+ );
+ "#,
+ None,
+ ),
+ (r#"<>{moo}>"#, None),
+ ];
+
+ Tester::new(JsxNoUselessFragment::NAME, pass, fail).test_and_snapshot();
+}
diff --git a/crates/oxc_linter/src/snapshots/jsx_no_useless_fragment.snap b/crates/oxc_linter/src/snapshots/jsx_no_useless_fragment.snap
new file mode 100644
index 000000000..201df1e67
--- /dev/null
+++ b/crates/oxc_linter/src/snapshots/jsx_no_useless_fragment.snap
@@ -0,0 +1,166 @@
+---
+source: crates/oxc_linter/src/tester.rs
+expression: jsx_no_useless_fragment
+---
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>>
+ · ─────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{}>
+ · ───────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ moo<>foo>
+ · ────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ moo<>foo>
+ · ────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{meow}>
+ · ───────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{meow}>
+ · ──────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{meow}>
+ · ──────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>>
+ · ───────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │
+ 2 │ ╭─▶ <>
+ 3 │ │
+ 4 │ ╰─▶ >
+ 5 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │
+ · ────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │
+ 2 │ ╭─▶
+ 3 │ │
+ 4 │ ╰─▶
+ 5 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>foo>
+ · ──────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>foo>
+ · ─────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>foo>
+ · ─────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{"a"}{"b"}>
+ · ────────────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{"a"}{"b"}>
+ · ────────────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:4:1]
+ 4 │
+ 5 │ <>{"a"}{"b"}>
+ · ───────────────
+ 6 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ {"a"}{"b"}
+ · ────────────────────────────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:2:1]
+ 2 │
+ 3 │ ╭─▶ git<>
+ 4 │ │ hub.
+ 5 │ ╰─▶ >
+ 6 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:6:1]
+ 6 │
+ 7 │ git<> hub>
+ · ────────────────
+ 8 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ a <>{""}{""}> a
+ · ─────────────
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:3:1]
+ 3 │
+ 4 │
+ · ──────────────────
+ 5 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Passing a fragment to a HTML element is useless.
+ ╭─[jsx_no_useless_fragment.tsx:3:1]
+ 3 │
+ 4 │
+ · ──────────────────
+ 5 │
+ ╰────
+
+ ⚠ eslint-plugin-react(jsx-no-useless-fragment): Fragments should contain more than one child.
+ ╭─[jsx_no_useless_fragment.tsx:1:1]
+ 1 │ <>{moo}>
+ · ─────────────────────
+ ╰────
+
+