Skip to content

File tree

3 files changed

+395
-0
lines changed

3 files changed

+395
-0
lines changed

crates/oxc_linter/src/rules.rs

+2
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ mod jest {
180180

181181
mod react {
182182
pub mod button_has_type;
183+
pub mod checked_requires_onchange_or_readonly;
183184
pub mod jsx_key;
184185
pub mod jsx_no_comment_textnodes;
185186
pub mod jsx_no_duplicate_props;
@@ -572,6 +573,7 @@ oxc_macros::declare_all_lint_rules! {
572573
unicorn::text_encoding_identifier_case,
573574
unicorn::throw_new_error,
574575
react::button_has_type,
576+
react::checked_requires_onchange_or_readonly,
575577
react::jsx_no_target_blank,
576578
react::jsx_key,
577579
react::jsx_no_comment_textnodes,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
use oxc_ast::{
2+
ast::{Argument, Expression, JSXAttributeItem, ObjectPropertyKind},
3+
AstKind,
4+
};
5+
use oxc_diagnostics::{
6+
miette::{self, Diagnostic},
7+
thiserror::Error,
8+
};
9+
use oxc_macros::declare_oxc_lint;
10+
use oxc_span::Span;
11+
12+
use crate::{
13+
context::LintContext,
14+
rule::Rule,
15+
utils::{get_element_type, get_jsx_attribute_name, is_create_element_call},
16+
AstNode,
17+
};
18+
19+
#[derive(Debug, Error, Diagnostic)]
20+
enum CheckedRequiresOnchangeOrReadonlyDiagnostic {
21+
#[error("eslint-plugin-react(checked-requires-onchange-or-readonly): `checked` should be used with either `onChange` or `readOnly`.")]
22+
#[diagnostic(severity(warning), help("Add either `onChange` or `readOnly`."))]
23+
MissingProperty(#[label] Span),
24+
25+
#[error("eslint-plugin-react(checked-requires-onchange-or-readonly): Use either `checked` or `defaultChecked`, but not both.")]
26+
#[diagnostic(severity(warning), help("Remove either `checked` or `defaultChecked`."))]
27+
ExclusiveCheckedAttribute(#[label] Span, #[label] Span),
28+
}
29+
30+
#[derive(Debug, Default, Clone)]
31+
pub struct CheckedRequiresOnchangeOrReadonly {
32+
ignore_missing_properties: bool,
33+
ignore_exclusive_checked_attribute: bool,
34+
}
35+
36+
declare_oxc_lint!(
37+
/// ### What it does
38+
/// This rule enforces onChange or readonly attribute for checked property of input elements.
39+
/// It also warns when checked and defaultChecked properties are used together.
40+
///
41+
/// ### Example
42+
/// ```javascript
43+
/// // Bad
44+
/// <input type="checkbox" checked />
45+
/// <input type="checkbox" checked defaultChecked />
46+
/// <input type="radio" checked defaultChecked />
47+
///
48+
/// React.createElement('input', { checked: false });
49+
/// React.createElement('input', { type: 'checkbox', checked: true });
50+
/// React.createElement('input', { type: 'checkbox', checked: true, defaultChecked: true });
51+
///
52+
/// // Good
53+
/// <input type="checkbox" checked onChange={() => {}} />
54+
/// <input type="checkbox" checked readOnly />
55+
/// <input type="checkbox" checked onChange readOnly />
56+
/// <input type="checkbox" defaultChecked />
57+
///
58+
/// React.createElement('input', { type: 'checkbox', checked: true, onChange() {} });
59+
/// React.createElement('input', { type: 'checkbox', checked: true, readOnly: true });
60+
/// React.createElement('input', { type: 'checkbox', checked: true, onChange() {}, readOnly: true });
61+
/// React.createElement('input', { type: 'checkbox', defaultChecked: true });
62+
/// ```
63+
CheckedRequiresOnchangeOrReadonly,
64+
correctness
65+
);
66+
67+
impl Rule for CheckedRequiresOnchangeOrReadonly {
68+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
69+
match node.kind() {
70+
AstKind::JSXOpeningElement(jsx_opening_el) => {
71+
let Some(element_type) = get_element_type(ctx, jsx_opening_el) else { return };
72+
if element_type != "input" {
73+
return;
74+
}
75+
76+
let (checked_span, default_checked_span, is_missing_property) =
77+
jsx_opening_el.attributes.iter().fold(
78+
(None, None, true),
79+
|(checked_span, default_checked_span, is_missing_property), attr| {
80+
if let JSXAttributeItem::Attribute(jsx_attr) = attr {
81+
let name = get_jsx_attribute_name(&jsx_attr.name);
82+
(
83+
if name == "checked" {
84+
Some(jsx_attr.span)
85+
} else {
86+
checked_span
87+
},
88+
if default_checked_span.is_none() && name == "defaultChecked" {
89+
Some(jsx_attr.span)
90+
} else {
91+
default_checked_span
92+
},
93+
is_missing_property
94+
&& !(name == "onChange" || name == "readOnly"),
95+
)
96+
} else {
97+
(checked_span, default_checked_span, is_missing_property)
98+
}
99+
},
100+
);
101+
102+
if let Some(checked_span) = checked_span {
103+
if !self.ignore_exclusive_checked_attribute {
104+
if let Some(default_checked_span) = default_checked_span {
105+
ctx.diagnostic(
106+
CheckedRequiresOnchangeOrReadonlyDiagnostic::ExclusiveCheckedAttribute(
107+
checked_span,
108+
default_checked_span,
109+
),
110+
);
111+
}
112+
}
113+
114+
if !self.ignore_missing_properties && is_missing_property {
115+
ctx.diagnostic(
116+
CheckedRequiresOnchangeOrReadonlyDiagnostic::MissingProperty(
117+
checked_span,
118+
),
119+
);
120+
}
121+
}
122+
}
123+
AstKind::CallExpression(call_expr) => {
124+
if !is_create_element_call(call_expr) {
125+
return;
126+
}
127+
128+
let Some(Argument::Expression(Expression::StringLiteral(element_name))) =
129+
call_expr.arguments.first()
130+
else {
131+
return;
132+
};
133+
134+
if element_name.value != "input" {
135+
return;
136+
}
137+
138+
let Some(Argument::Expression(Expression::ObjectExpression(obj_expr))) =
139+
call_expr.arguments.get(1)
140+
else {
141+
return;
142+
};
143+
144+
let (checked_span, default_checked_span, is_missing_property) =
145+
obj_expr.properties.iter().fold(
146+
(None, None, true),
147+
|(checked_span, default_checked_span, is_missing_property), prop| {
148+
if let ObjectPropertyKind::ObjectProperty(object_prop) = prop {
149+
if let Some(name) = object_prop.key.static_name() {
150+
(
151+
if checked_span.is_none() && name == "checked" {
152+
Some(object_prop.span)
153+
} else {
154+
checked_span
155+
},
156+
if default_checked_span.is_none()
157+
&& name == "defaultChecked"
158+
{
159+
Some(object_prop.span)
160+
} else {
161+
default_checked_span
162+
},
163+
is_missing_property
164+
&& !(name == "onChange" || name == "readOnly"),
165+
)
166+
} else {
167+
(checked_span, default_checked_span, is_missing_property)
168+
}
169+
} else {
170+
(checked_span, default_checked_span, is_missing_property)
171+
}
172+
},
173+
);
174+
175+
if let Some(checked_span) = checked_span {
176+
if !self.ignore_exclusive_checked_attribute {
177+
if let Some(default_checked_span) = default_checked_span {
178+
ctx.diagnostic(
179+
CheckedRequiresOnchangeOrReadonlyDiagnostic::ExclusiveCheckedAttribute(
180+
checked_span,
181+
default_checked_span,
182+
),
183+
);
184+
}
185+
}
186+
187+
if !self.ignore_missing_properties && is_missing_property {
188+
ctx.diagnostic(
189+
CheckedRequiresOnchangeOrReadonlyDiagnostic::MissingProperty(
190+
checked_span,
191+
),
192+
);
193+
}
194+
}
195+
}
196+
_ => {}
197+
}
198+
}
199+
200+
fn from_configuration(value: serde_json::Value) -> Self {
201+
let value = value.as_array().and_then(|arr| arr.first()).and_then(|val| val.as_object());
202+
203+
Self {
204+
ignore_missing_properties: value
205+
.and_then(|val| {
206+
val.get("ignoreMissingProperties").and_then(serde_json::Value::as_bool)
207+
})
208+
.unwrap_or(false),
209+
ignore_exclusive_checked_attribute: value
210+
.and_then(|val| {
211+
val.get("ignoreExclusiveCheckedAttribute").and_then(serde_json::Value::as_bool)
212+
})
213+
.unwrap_or(false),
214+
}
215+
}
216+
}
217+
218+
#[test]
219+
fn test() {
220+
use crate::tester::Tester;
221+
222+
let pass = vec![
223+
(r"<input type='checkbox' />", None),
224+
(r"<input type='checkbox' onChange={noop} />", None),
225+
(r"<input type='checkbox' readOnly />", None),
226+
(r"<input type='checkbox' checked onChange={noop} />", None),
227+
(r"<input type='checkbox' checked={true} onChange={noop} />", None),
228+
(r"<input type='checkbox' checked={false} onChange={noop} />", None),
229+
(r"<input type='checkbox' checked readOnly />", None),
230+
(r"<input type='checkbox' checked={true} readOnly />", None),
231+
(r"<input type='checkbox' checked={false} readOnly />", None),
232+
(r"<input type='checkbox' defaultChecked />", None),
233+
(r"React.createElement('input')", None),
234+
(r"React.createElement('input', { checked: true, onChange: noop })", None),
235+
(r"React.createElement('input', { checked: false, onChange: noop })", None),
236+
(r"React.createElement('input', { checked: true, readOnly: true })", None),
237+
(r"React.createElement('input', { checked: true, onChange: noop, readOnly: true })", None),
238+
(r"React.createElement('input', { checked: foo, onChange: noop, readOnly: true })", None),
239+
(
240+
r"<input type='checkbox' checked />",
241+
Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
242+
),
243+
(
244+
r"<input type='checkbox' checked={true} />",
245+
Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
246+
),
247+
(
248+
r"<input type='checkbox' onChange={noop} checked defaultChecked />",
249+
Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
250+
),
251+
(
252+
r"<input type='checkbox' onChange={noop} checked={true} defaultChecked />",
253+
Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
254+
),
255+
(
256+
r"<input type='checkbox' onChange={noop} checked defaultChecked />",
257+
Some(
258+
serde_json::json!([{ "ignoreMissingProperties": true, "ignoreExclusiveCheckedAttribute": true }]),
259+
),
260+
),
261+
(r"<span/>", None),
262+
(r"React.createElement('span')", None),
263+
(r"(()=>{})()", None),
264+
];
265+
266+
let fail = vec![
267+
(r"<input type='radio' checked />", None),
268+
(r"<input type='radio' checked={true} />", None),
269+
(r"<input type='checkbox' checked />", None),
270+
(r"<input type='checkbox' checked={true} />", None),
271+
(r"<input type='checkbox' checked={condition ? true : false} />", None),
272+
(r"<input type='checkbox' checked defaultChecked />", None),
273+
(r"React.createElement('input', { checked: false })", None),
274+
(r"React.createElement('input', { checked: true, defaultChecked: true })", None),
275+
(
276+
r"<input type='checkbox' checked defaultChecked />",
277+
Some(serde_json::json!([{ "ignoreMissingProperties": true }])),
278+
),
279+
(
280+
r"<input type='checkbox' checked defaultChecked />",
281+
Some(serde_json::json!([{ "ignoreExclusiveCheckedAttribute": true }])),
282+
),
283+
(
284+
r"<input type='checkbox' checked defaultChecked />",
285+
Some(
286+
serde_json::json!([{ "ignoreMissingProperties": false, "ignoreExclusiveCheckedAttribute": false }]),
287+
),
288+
),
289+
];
290+
291+
Tester::new(CheckedRequiresOnchangeOrReadonly::NAME, pass, fail).test_and_snapshot();
292+
}

0 commit comments

Comments
 (0)