diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index 89fe952614681..7ec40bbb0e0ed 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -245,6 +245,7 @@ mod react {
pub mod react_in_jsx_scope;
pub mod require_render_return;
pub mod rules_of_hooks;
+ pub mod self_closing_comp;
pub mod void_dom_elements_no_children;
}
@@ -772,6 +773,7 @@ oxc_macros::declare_all_lint_rules! {
react::prefer_es6_class,
react::require_render_return,
react::rules_of_hooks,
+ react::self_closing_comp,
react::void_dom_elements_no_children,
react_perf::jsx_no_jsx_as_prop,
react_perf::jsx_no_new_array_as_prop,
diff --git a/crates/oxc_linter/src/rules/react/self_closing_comp.rs b/crates/oxc_linter/src/rules/react/self_closing_comp.rs
new file mode 100644
index 0000000000000..88279aebafe0e
--- /dev/null
+++ b/crates/oxc_linter/src/rules/react/self_closing_comp.rs
@@ -0,0 +1,349 @@
+use oxc_ast::{
+ ast::{JSXChild, JSXElementName},
+ AstKind,
+};
+use oxc_diagnostics::OxcDiagnostic;
+use oxc_macros::declare_oxc_lint;
+use oxc_span::Span;
+
+use crate::{context::LintContext, globals::HTML_TAG, rule::Rule, AstNode};
+
+fn self_closing_comp_diagnostic(span: Span) -> OxcDiagnostic {
+ OxcDiagnostic::warn("Unnecessary closing tag")
+ .with_help("Make the component self closing")
+ .with_label(span)
+}
+
+#[derive(Debug, Clone)]
+pub struct SelfClosingComp {
+ component: bool,
+ html: bool,
+}
+
+impl Default for SelfClosingComp {
+ fn default() -> Self {
+ Self { component: true, html: true }
+ }
+}
+
+declare_oxc_lint!(
+ /// ### What it does
+ ///
+ /// Detects components without children which can be self-closed to avoid unnecessary extra
+ /// closing tags.
+ ///
+ /// A self closing component which contains whitespace is allowed except when it also contains
+ /// a newline.
+ ///
+ /// ### Examples
+ ///
+ /// Examples of **incorrect** code for this rule:
+ /// ```jsx
+ /// const elem =
+ /// const dom_elem =
+ /// const welem =
+ ///
+ ///
+ /// ```
+ ///
+ /// Examples of **correct** code for this rule:
+ /// ```jsx
+ /// const elem =
+ /// const welem =
+ /// const dom_elem =
+ /// ```
+ SelfClosingComp,
+ style,
+ pending
+);
+
+impl Rule for SelfClosingComp {
+ fn from_configuration(value: serde_json::Value) -> Self {
+ let obj = value.get(0);
+
+ Self {
+ component: obj
+ .and_then(|v| v.get("component"))
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(true),
+ html: obj
+ .and_then(|v| v.get("html"))
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(true),
+ }
+ }
+
+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
+ let AstKind::JSXElement(jsx_el) = node.kind() else {
+ return;
+ };
+
+ if jsx_el.opening_element.self_closing {
+ return;
+ }
+
+ if jsx_el.children.len() > 1 {
+ return;
+ }
+
+ // The eslint react rule disallows multiline whitespace lines, but allows lines with
+ // whitespace
+ if jsx_el.children.len() == 1 {
+ let JSXChild::Text(jsx_text) = &jsx_el.children[0] else {
+ return;
+ };
+
+ if !(jsx_text.value.contains('\n') && jsx_text.value.chars().all(char::is_whitespace)) {
+ return;
+ }
+ }
+
+ let Some(jsx_closing_elem) = &jsx_el.closing_element else {
+ return;
+ };
+
+ let is_comp = matches!(
+ jsx_el.opening_element.name,
+ JSXElementName::MemberExpression(_) | JSXElementName::NamespacedName(_)
+ );
+
+ let mut is_dom_comp = false;
+ if !is_comp {
+ if let Some(tag_name) = jsx_el.opening_element.name.get_identifier_name() {
+ is_dom_comp = HTML_TAG.contains(&tag_name);
+ };
+ }
+
+ if self.html && is_dom_comp || self.component && !is_dom_comp {
+ ctx.diagnostic(self_closing_comp_diagnostic(jsx_closing_elem.span));
+ }
+ }
+
+ fn should_run(&self, ctx: &LintContext) -> bool {
+ ctx.source_type().is_jsx()
+ }
+}
+
+#[test]
+fn test() {
+ use crate::tester::Tester;
+
+ let pass = vec![
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var Profile = ;"#, None),
+ (
+ r#"var Profile = ;"#,
+ None,
+ ),
+ (
+ r#"
+
+
+
+ "#,
+ None,
+ ),
+ (
+ r#"
+
+
+
+ "#,
+ None,
+ ),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, None),
+ ("var HelloJohn =
;", None),
+ ("var HelloJohn = {' '}
;", None),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))),
+ (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))),
+ (
+ r#"var Profile = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var Profile = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"
+
+
+ "#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"
+
+
+ "#,
+ Some(serde_json::json!([])),
+ ),
+ ("var HelloJohn =
;", Some(serde_json::json!([]))),
+ ("var HelloJohn =
;", Some(serde_json::json!([]))),
+ ("var HelloJohn =
;", Some(serde_json::json!([]))),
+ ("var HelloJohn = {' '}
;", Some(serde_json::json!([]))),
+ (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([{ "component": false }])),
+ ),
+ (
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ (
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ (
+ r#"
+
+ "#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ ];
+
+ let fail = vec![
+ (r#"var contentContainer = ;"#, None),
+ (r#"var contentContainer = ;"#, Some(serde_json::json!([]))),
+ (r#"var HelloJohn = ;"#, None),
+ (r#"var CompoundHelloJohn = ;"#, None),
+ (
+ r#"const HelloJohn =
+ ;"#,
+ None,
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ None,
+ ),
+ (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))),
+ (
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ (
+ r#"var contentContainer =
+
;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ ];
+
+ let _fix = vec![
+ (
+ r#"var contentContainer = ;"#,
+ r#"var contentContainer = ;"#,
+ None,
+ ),
+ (
+ r#"var contentContainer = ;"#,
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ r#"var HelloJohn = ;"#,
+ None,
+ ),
+ (
+ r#"var CompoundHelloJohn = ;"#,
+ r#"var CompoundHelloJohn = ;"#,
+ None,
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ r#"var HelloJohn = ;"#,
+ None,
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ r#"var HelloJohn = ;"#,
+ None,
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn = ;"#,
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var HelloJohn =
+ ;"#,
+ r#"var HelloJohn = ;"#,
+ Some(serde_json::json!([])),
+ ),
+ (
+ r#"var contentContainer = ;"#,
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ (
+ r#"var contentContainer =
+
;"#,
+ r#"var contentContainer = ;"#,
+ Some(serde_json::json!([{ "html": true }])),
+ ),
+ ];
+ Tester::new(SelfClosingComp::NAME, pass, fail).test_and_snapshot();
+}
diff --git a/crates/oxc_linter/src/snapshots/self_closing_comp.snap b/crates/oxc_linter/src/snapshots/self_closing_comp.snap
new file mode 100644
index 0000000000000..68b3cc54d13b9
--- /dev/null
+++ b/crates/oxc_linter/src/snapshots/self_closing_comp.snap
@@ -0,0 +1,91 @@
+---
+source: crates/oxc_linter/src/tester.rs
+---
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:49]
+ 1 │ var contentContainer = ;
+ · ──────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:49]
+ 1 │ var contentContainer = ;
+ · ──────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:36]
+ 1 │ var HelloJohn = ;
+ · ────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:53]
+ 1 │ var CompoundHelloJohn = ;
+ · ─────────────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:2:4]
+ 1 │ const HelloJohn =
+ 2 │ ;
+ · ────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:2:4]
+ 1 │ var HelloJohn =
+ 2 │ ;
+ · ─────────────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:36]
+ 1 │ var HelloJohn = ;
+ · ────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:45]
+ 1 │ var HelloJohn = ;
+ · ─────────────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:2:4]
+ 1 │ var HelloJohn =
+ 2 │ ;
+ · ────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:2:4]
+ 1 │ var HelloJohn =
+ 2 │ ;
+ · ─────────────────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:1:49]
+ 1 │ var contentContainer = ;
+ · ──────
+ ╰────
+ help: Make the component self closing
+
+ ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag
+ ╭─[self_closing_comp.tsx:2:4]
+ 1 │ var contentContainer =
+ 2 │
;
+ · ──────
+ ╰────
+ help: Make the component self closing