diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index 872a64c7ce3d5..d1c8a2b7ca485 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -180,6 +180,7 @@ mod jest {
mod react {
pub mod button_has_type;
+ pub mod checked_requires_onchange_or_readonly;
pub mod jsx_key;
pub mod jsx_no_comment_textnodes;
pub mod jsx_no_duplicate_props;
@@ -572,6 +573,7 @@ oxc_macros::declare_all_lint_rules! {
unicorn::text_encoding_identifier_case,
unicorn::throw_new_error,
react::button_has_type,
+ react::checked_requires_onchange_or_readonly,
react::jsx_no_target_blank,
react::jsx_key,
react::jsx_no_comment_textnodes,
diff --git a/crates/oxc_linter/src/rules/react/checked_requires_onchange_or_readonly.rs b/crates/oxc_linter/src/rules/react/checked_requires_onchange_or_readonly.rs
new file mode 100644
index 0000000000000..227a2c4602cac
--- /dev/null
+++ b/crates/oxc_linter/src/rules/react/checked_requires_onchange_or_readonly.rs
@@ -0,0 +1,292 @@
+use oxc_ast::{
+ ast::{Argument, Expression, JSXAttributeItem, ObjectPropertyKind},
+ AstKind,
+};
+use oxc_diagnostics::{
+ miette::{self, Diagnostic},
+ thiserror::Error,
+};
+use oxc_macros::declare_oxc_lint;
+use oxc_span::Span;
+
+use crate::{
+ context::LintContext,
+ rule::Rule,
+ utils::{get_element_type, get_jsx_attribute_name, is_create_element_call},
+ AstNode,
+};
+
+#[derive(Debug, Error, Diagnostic)]
+enum CheckedRequiresOnchangeOrReadonlyDiagnostic {
+ #[error("eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.")]
+ #[diagnostic(severity(warning), help("Add either `onChange` or `readOnly`."))]
+ MissingProperty(#[label] Span),
+
+ #[error("eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.")]
+ #[diagnostic(severity(warning), help("Remove either `checked` or `defaultChecked`."))]
+ ExclusiveCheckedAttribute(#[label] Span, #[label] Span),
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct CheckedRequiresOnchangeOrReadonly {
+ ignore_missing_properties: bool,
+ ignore_exclusive_checked_attribute: bool,
+}
+
+declare_oxc_lint!(
+ /// ### What it does
+ /// This rule enforces onChange or readonly attribute for checked property of input elements.
+ /// It also warns when checked and defaultChecked properties are used together.
+ ///
+ /// ### Example
+ /// ```javascript
+ /// // Bad
+ ///
+ ///
+ ///
+ ///
+ /// React.createElement('input', { checked: false });
+ /// React.createElement('input', { type: 'checkbox', checked: true });
+ /// React.createElement('input', { type: 'checkbox', checked: true, defaultChecked: true });
+ ///
+ /// // Good
+ /// {}} />
+ ///
+ ///
+ ///
+ ///
+ /// React.createElement('input', { type: 'checkbox', checked: true, onChange() {} });
+ /// React.createElement('input', { type: 'checkbox', checked: true, readOnly: true });
+ /// React.createElement('input', { type: 'checkbox', checked: true, onChange() {}, readOnly: true });
+ /// React.createElement('input', { type: 'checkbox', defaultChecked: true });
+ /// ```
+ CheckedRequiresOnchangeOrReadonly,
+ correctness
+);
+
+impl Rule for CheckedRequiresOnchangeOrReadonly {
+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
+ match node.kind() {
+ AstKind::JSXOpeningElement(jsx_opening_el) => {
+ let Some(element_type) = get_element_type(ctx, jsx_opening_el) else { return };
+ if element_type != "input" {
+ return;
+ }
+
+ let (checked_span, default_checked_span, is_missing_property) =
+ jsx_opening_el.attributes.iter().fold(
+ (None, None, true),
+ |(checked_span, default_checked_span, is_missing_property), attr| {
+ if let JSXAttributeItem::Attribute(jsx_attr) = attr {
+ let name = get_jsx_attribute_name(&jsx_attr.name);
+ (
+ if name == "checked" {
+ Some(jsx_attr.span)
+ } else {
+ checked_span
+ },
+ if default_checked_span.is_none() && name == "defaultChecked" {
+ Some(jsx_attr.span)
+ } else {
+ default_checked_span
+ },
+ is_missing_property
+ && !(name == "onChange" || name == "readOnly"),
+ )
+ } else {
+ (checked_span, default_checked_span, is_missing_property)
+ }
+ },
+ );
+
+ if let Some(checked_span) = checked_span {
+ if !self.ignore_exclusive_checked_attribute {
+ if let Some(default_checked_span) = default_checked_span {
+ ctx.diagnostic(
+ CheckedRequiresOnchangeOrReadonlyDiagnostic::ExclusiveCheckedAttribute(
+ checked_span,
+ default_checked_span,
+ ),
+ );
+ }
+ }
+
+ if !self.ignore_missing_properties && is_missing_property {
+ ctx.diagnostic(
+ CheckedRequiresOnchangeOrReadonlyDiagnostic::MissingProperty(
+ checked_span,
+ ),
+ );
+ }
+ }
+ }
+ AstKind::CallExpression(call_expr) => {
+ if !is_create_element_call(call_expr) {
+ return;
+ }
+
+ let Some(Argument::Expression(Expression::StringLiteral(element_name))) =
+ call_expr.arguments.first()
+ else {
+ return;
+ };
+
+ if element_name.value != "input" {
+ return;
+ }
+
+ let Some(Argument::Expression(Expression::ObjectExpression(obj_expr))) =
+ call_expr.arguments.get(1)
+ else {
+ return;
+ };
+
+ let (checked_span, default_checked_span, is_missing_property) =
+ obj_expr.properties.iter().fold(
+ (None, None, true),
+ |(checked_span, default_checked_span, is_missing_property), prop| {
+ if let ObjectPropertyKind::ObjectProperty(object_prop) = prop {
+ if let Some(name) = object_prop.key.static_name() {
+ (
+ if checked_span.is_none() && name == "checked" {
+ Some(object_prop.span)
+ } else {
+ checked_span
+ },
+ if default_checked_span.is_none()
+ && name == "defaultChecked"
+ {
+ Some(object_prop.span)
+ } else {
+ default_checked_span
+ },
+ is_missing_property
+ && !(name == "onChange" || name == "readOnly"),
+ )
+ } else {
+ (checked_span, default_checked_span, is_missing_property)
+ }
+ } else {
+ (checked_span, default_checked_span, is_missing_property)
+ }
+ },
+ );
+
+ if let Some(checked_span) = checked_span {
+ if !self.ignore_exclusive_checked_attribute {
+ if let Some(default_checked_span) = default_checked_span {
+ ctx.diagnostic(
+ CheckedRequiresOnchangeOrReadonlyDiagnostic::ExclusiveCheckedAttribute(
+ checked_span,
+ default_checked_span,
+ ),
+ );
+ }
+ }
+
+ if !self.ignore_missing_properties && is_missing_property {
+ ctx.diagnostic(
+ CheckedRequiresOnchangeOrReadonlyDiagnostic::MissingProperty(
+ checked_span,
+ ),
+ );
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn from_configuration(value: serde_json::Value) -> Self {
+ let value = value.as_array().and_then(|arr| arr.first()).and_then(|val| val.as_object());
+
+ Self {
+ ignore_missing_properties: value
+ .and_then(|val| {
+ val.get("ignoreMissingProperties").and_then(serde_json::Value::as_bool)
+ })
+ .unwrap_or(false),
+ ignore_exclusive_checked_attribute: value
+ .and_then(|val| {
+ val.get("ignoreExclusiveCheckedAttribute").and_then(serde_json::Value::as_bool)
+ })
+ .unwrap_or(false),
+ }
+ }
+}
+
+#[test]
+fn test() {
+ use crate::tester::Tester;
+
+ let pass = vec![
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"React.createElement('input')", None),
+ (r"React.createElement('input', { checked: true, onChange: noop })", None),
+ (r"React.createElement('input', { checked: false, onChange: noop })", None),
+ (r"React.createElement('input', { checked: true, readOnly: true })", None),
+ (r"React.createElement('input', { checked: true, onChange: noop, readOnly: true })", None),
+ (r"React.createElement('input', { checked: foo, onChange: noop, readOnly: true })", None),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
+ ),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
+ ),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
+ ),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
+ ),
+ (
+ r"",
+ Some(
+ serde_json::json!([{ "ignoreMissingProperties": true, "ignoreExclusiveCheckedAttribute": true }]),
+ ),
+ ),
+ (r"", None),
+ (r"React.createElement('span')", None),
+ (r"(()=>{})()", None),
+ ];
+
+ let fail = vec![
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"", None),
+ (r"React.createElement('input', { checked: false })", None),
+ (r"React.createElement('input', { checked: true, defaultChecked: true })", None),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
+ ),
+ (
+ r"",
+ Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
+ ),
+ (
+ r"",
+ Some(
+ serde_json::json!([{ "ignoreMissingProperties": false, "ignoreExclusiveCheckedAttribute": false }]),
+ ),
+ ),
+ ];
+
+ Tester::new(CheckedRequiresOnchangeOrReadonly::NAME, pass, fail).test_and_snapshot();
+}
diff --git a/crates/oxc_linter/src/snapshots/checked_requires_onchange_or_readonly.snap b/crates/oxc_linter/src/snapshots/checked_requires_onchange_or_readonly.snap
new file mode 100644
index 0000000000000..1fa82834fa165
--- /dev/null
+++ b/crates/oxc_linter/src/snapshots/checked_requires_onchange_or_readonly.snap
@@ -0,0 +1,101 @@
+---
+source: crates/oxc_linter/src/tester.rs
+expression: checked_requires_onchange_or_readonly
+---
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:21]
+ 1 │
+ · ───────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:21]
+ 1 │
+ · ──────────────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ───────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ──────────────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ──────────────────────────────────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ─────── ──────────────
+ ╰────
+ help: Remove either `checked` or `defaultChecked`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ───────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:32]
+ 1 │ React.createElement('input', { checked: false })
+ · ──────────────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:32]
+ 1 │ React.createElement('input', { checked: true, defaultChecked: true })
+ · ───────────── ────────────────────
+ ╰────
+ help: Remove either `checked` or `defaultChecked`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:32]
+ 1 │ React.createElement('input', { checked: true, defaultChecked: true })
+ · ─────────────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ─────── ──────────────
+ ╰────
+ help: Remove either `checked` or `defaultChecked`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ───────
+ ╰────
+ help: Add either `onChange` or `readOnly`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ─────── ──────────────
+ ╰────
+ help: Remove either `checked` or `defaultChecked`.
+
+ ⚠ eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.
+ ╭─[checked_requires_onchange_or_readonly.tsx:1:24]
+ 1 │
+ · ───────
+ ╰────
+ help: Add either `onChange` or `readOnly`.