diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 9761e260b1f3e..b4e804c658d1e 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -261,6 +261,7 @@ mod react { pub mod require_render_return; pub mod rules_of_hooks; pub mod self_closing_comp; + pub mod style_prop_object; pub mod void_dom_elements_no_children; } @@ -808,6 +809,7 @@ oxc_macros::declare_all_lint_rules! { react::require_render_return, react::rules_of_hooks, react::self_closing_comp, + react::style_prop_object, 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/style_prop_object.rs b/crates/oxc_linter/src/rules/react/style_prop_object.rs new file mode 100644 index 0000000000000..d0d6b1c0dc0b1 --- /dev/null +++ b/crates/oxc_linter/src/rules/react/style_prop_object.rs @@ -0,0 +1,497 @@ +use oxc_ast::{ + ast::{ + Argument, Expression, JSXAttribute, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, + JSXElementName, ObjectPropertyKind, TSType, + }, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{CompactStr, GetSpan, Span}; + +use crate::{ + ast_util::get_declaration_of_variable, + context::{ContextHost, LintContext}, + rule::Rule, + utils::is_create_element_call, + AstNode, +}; + +fn style_prop_object_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Style prop value must be an object").with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct StylePropObject(Box); + +#[derive(Debug, Default, Clone)] +pub struct StylePropObjectConfig { + allow: Vec, +} + +impl std::ops::Deref for StylePropObject { + type Target = StylePropObjectConfig; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +declare_oxc_lint!( + /// ### What it does + /// Require that the value of the prop `style` be an object or a variable that is an object. + /// + /// ### Why is this bad? + /// The `style` prop expects an object mapping from style properties to values when using JSX. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + ///
+ ///
+ /// + /// const styles = true; + ///
+ /// + /// React.createElement("div", { style: "color: 'red'" }); + /// React.createElement("div", { style: true }); + /// React.createElement("Hello", { style: true }); + /// const styles = true; + /// React.createElement("div", { style: styles }); + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + ///
+ /// + /// const styles = { color: "red" }; + ///
+ /// + /// React.createElement("div", { style: { color: 'red' }}); + /// React.createElement("Hello", { style: { color: 'red' }}); + /// const styles = { height: '100px' }; + /// React.createElement("div", { style: styles }); + /// ``` + StylePropObject, + suspicious +); + +fn is_invalid_type(ty: &TSType) -> bool { + match ty { + TSType::TSNumberKeyword(_) | TSType::TSStringKeyword(_) | TSType::TSBooleanKeyword(_) => { + true + } + TSType::TSUnionType(union) => union.types.iter().any(is_invalid_type), + TSType::TSIntersectionType(intersect) => intersect.types.iter().any(is_invalid_type), + _ => false, + } +} + +fn is_invalid_expression<'a>(expression: Option<&Expression<'a>>, ctx: &LintContext<'a>) -> bool { + let Some(expression) = expression else { + return false; + }; + + match expression { + Expression::StringLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::TemplateLiteral(_) => true, + Expression::Identifier(ident) => { + let Some(node) = get_declaration_of_variable(ident, ctx) else { + return false; + }; + + let AstKind::VariableDeclarator(var_decl) = node.kind() else { + return false; + }; + + if let Some(asd) = var_decl.id.type_annotation.as_ref() { + return is_invalid_type(&asd.type_annotation); + }; + + return is_invalid_expression(var_decl.init.as_ref(), ctx); + } + _ => false, + } +} + +fn is_invalid_style_attribute<'a>(attribute: &JSXAttribute<'a>, ctx: &LintContext<'a>) -> bool { + let JSXAttributeName::Identifier(attr_ident) = &attribute.name else { + return false; + }; + + if attr_ident.name == "style" { + if let Some(attr_value) = &attribute.value { + return match attr_value { + JSXAttributeValue::StringLiteral(_) => true, + JSXAttributeValue::ExpressionContainer(container) => { + return is_invalid_expression(container.expression.as_expression(), ctx); + } + _ => false, + }; + } + } + + false +} + +impl Rule for StylePropObject { + fn from_configuration(value: serde_json::Value) -> Self { + let mut allow = value + .get(0) + .and_then(|v| v.get("allow")) + .and_then(serde_json::Value::as_array) + .map(|v| { + v.iter() + .filter_map(serde_json::Value::as_str) + .map(CompactStr::from) + .collect::>() + }) + .unwrap_or_default(); + + allow.sort(); + + Self(Box::new(StylePropObjectConfig { allow })) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXElement(jsx_elem) => { + let name = match &jsx_elem.opening_element.name { + JSXElementName::Identifier(id) => id.name.as_str(), + JSXElementName::IdentifierReference(id) => id.name.as_str(), + _ => return, + }; + + if self.allow.iter().any(|s| s == name) { + return; + } + + jsx_elem.opening_element.attributes.iter().for_each(|attribute| { + if let JSXAttributeItem::Attribute(attribute) = attribute { + if is_invalid_style_attribute(attribute, ctx) { + let Some(value) = &attribute.value else { + return; + }; + + ctx.diagnostic(style_prop_object_diagnostic(value.span())); + } + } + }); + } + AstKind::CallExpression(call_expr) => { + if !is_create_element_call(call_expr) { + return; + } + + let Some(component) = call_expr.arguments.first() else { + return; + }; + + let Some(expr) = component.as_expression() else { + return; + }; + + let name = match expr { + Expression::StringLiteral(literal) => literal.value.as_str(), + Expression::Identifier(identifier) => identifier.name.as_str(), + _ => return, + }; + + if self.allow.binary_search(&name.into()).is_ok() { + return; + } + + let Some(props) = call_expr.arguments.get(1) else { + return; + }; + + let Argument::ObjectExpression(obj_expr) = props else { + return; + }; + + for prop in &obj_expr.properties { + if let ObjectPropertyKind::ObjectProperty(obj_prop) = prop { + if let Some(prop_name) = obj_prop.key.static_name() { + if prop_name == "style" + && is_invalid_expression(Some(&obj_prop.value), ctx) + { + ctx.diagnostic(style_prop_object_diagnostic(obj_prop.value.span())); + } + } + } + } + } + _ => {} + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + (r#"
"#, None), + (r#"
"#, None), + (r#""#, None), + ( + r#" + function redDiv() { + const styles = { color: "red" }; + return
; + } + "#, + None, + ), + ( + r#" + function redDiv() { + const styles = { color: "red" }; + return ; + } + "#, + None, + ), + ( + r#" + const styles = { color: "red" }; + function redDiv() { + return
; + } + "#, + None, + ), + ( + r" + function redDiv(props) { + return
; + } + ", + None, + ), + ( + r" + import styles from './styles'; + function redDiv() { + return
; + } + ", + None, + ), + ( + r" + import mystyles from './styles'; + const styles = Object.assign({ color: 'red' }, mystyles); + function redDiv() { + return
; + } + ", + None, + ), + ( + r#" + const otherProps = { style: { color: "red" } }; + const { a, b, ...props } = otherProps; +
+ "#, + None, + ), + ( + r#" + const styles = Object.assign({ color: 'red' }, mystyles); + React.createElement("div", { style: styles }); + "#, + None, + ), + (r"
", None), + ( + r" + React.createElement(MyCustomElem, { + [style]: true + }, 'My custom Elem') + ", + None, + ), + ( + r" + let style; +
+ ", + None, + ), + ( + r" + let style = null; +
+ ", + None, + ), + ( + r" + let style = undefined; +
+ ", + None, + ), + ( + r" + const otherProps = { style: undefined }; + const { a, b, ...props } = otherProps; +
+ ", + None, + ), + ( + r#" + React.createElement("div", { + style: undefined + }) + "#, + None, + ), + ( + r#" + let style; + React.createElement("div", { + style + }) + "#, + None, + ), + ("
", None), + ( + r" + const props = { style: null }; +
+ ", + None, + ), + ( + r" + const otherProps = { style: null }; + const { a, b, ...props } = otherProps; +
+ ", + None, + ), + ( + r#" + React.createElement("div", { + style: null + }) + "#, + None, + ), + ( + r" + const MyComponent = (props) => { + React.createElement(MyCustomElem, { + ...props + }); + }; + ", + None, + ), + ( + r#""#, + Some(serde_json::json!([{ "allow": ["MyComponent"] }])), + ), + ( + r#"React.createElement(MyComponent, { style: "mySpecialStyle" })"#, + Some(serde_json::json!([{ "allow": ["MyComponent"] }])), + ), + ( + r" + let styles: object | undefined + return
+ ", + None, + ), + ( + r" + let styles: CSSProperties | undefined + return
+ ", + None, + ), + ]; + + let fail = vec![ + (r#"
"#, None), + (r#""#, None), + (r"
", None), + ( + r#" + const styles = `color: "red"`; + function redDiv2() { + return
; + } + "#, + None, + ), + ( + r#" + const styles = 'color: "red"'; + function redDiv2() { + return
; + } + "#, + None, + ), + ( + r#" + const styles = 'color: "blue"'; + function redDiv2() { + return ; + } + "#, + None, + ), + ( + r" + const styles = true; + function redDiv() { + return
; + } + ", + None, + ), + ( + r#""#, + Some(serde_json::json!([{ "allow": ["MyOtherComponent"] }])), + ), + ( + r#"React.createElement(MyComponent2, { style: "mySpecialStyle" })"#, + Some(serde_json::json!([{ "allow": ["MyOtherComponent"] }])), + ), + ( + r" + let styles: string | undefined + return
+ ", + None, + ), + ( + r" + let styles: number | undefined + return
+ ", + None, + ), + ( + r" + let styles: boolean | undefined + return
+ ", + None, + ), + ]; + + Tester::new(StylePropObject::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/style_prop_object.snap b/crates/oxc_linter/src/snapshots/style_prop_object.snap new file mode 100644 index 0000000000000..9b8f2ab0d6312 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/style_prop_object.snap @@ -0,0 +1,88 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:1:12] + 1 │
+ · ────────────── + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:1:14] + 1 │ + · ──────────────── + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:1:12] + 1 │
+ · ────── + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:4:35] + 3 │ function redDiv2() { + 4 │ return
; + · ──────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:4:35] + 3 │ function redDiv2() { + 4 │ return
; + · ──────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:4:37] + 3 │ function redDiv2() { + 4 │ return ; + · ──────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:4:35] + 3 │ function redDiv() { + 4 │ return
; + · ──────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:1:20] + 1 │ + · ───────── + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:1:44] + 1 │ React.createElement(MyComponent2, { style: "mySpecialStyle" }) + · ──────────────── + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:3:31] + 2 │ let styles: string | undefined + 3 │ return
+ · ──────── + 4 │ + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:3:31] + 2 │ let styles: number | undefined + 3 │ return
+ · ──────── + 4 │ + ╰──── + + ⚠ eslint-plugin-react(style-prop-object): Style prop value must be an object + ╭─[style_prop_object.tsx:3:31] + 2 │ let styles: boolean | undefined + 3 │ return
+ · ──────── + 4 │ + ╰────