use std::path::Path;
use oxc_ast::ast::{
ConditionalExpression, Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue,
JSXOpeningElement, LogicalExpression, ParenthesizedExpression,
};
use oxc_ast_visit::{walk, Visit};
use oxc_span::Span;
use crate::{
rules::{Issue, RuleContext, Severity},
utils::offset_to_line_col,
};
pub struct NoInlineJsxFn;
impl super::Rule for NoInlineJsxFn {
fn name(&self) -> &str {
"no_inline_jsx_fn"
}
fn run(&self, ctx: &RuleContext<'_>) -> Vec<Issue> {
let mut visitor = InlineFnVisitor {
issues: Vec::new(),
source_text: ctx.source_text,
file_path: ctx.file_path,
};
visitor.visit_program(ctx.program);
visitor.issues
}
}
struct InlineFnVisitor<'a> {
issues: Vec<Issue>,
source_text: &'a str,
file_path: &'a Path,
}
impl<'a> Visit<'a> for InlineFnVisitor<'_> {
fn visit_jsx_opening_element(&mut self, elem: &JSXOpeningElement<'a>) {
for attr_item in &elem.attributes {
match attr_item {
JSXAttributeItem::Attribute(attr) => {
let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value
else {
continue;
};
let Some(expr) = container.expression.as_expression() else {
continue;
};
let prop_name = extract_prop_name(&attr.name);
if is_memoized(expr) {
continue;
}
self.scan_for_inline_fn(expr, &prop_name);
}
JSXAttributeItem::SpreadAttribute(_) => {}
}
}
walk::walk_jsx_opening_element(self, elem);
}
}
impl InlineFnVisitor<'_> {
fn scan_for_inline_fn(&mut self, expr: &Expression<'_>, prop_name: &str) {
match expr {
Expression::ArrowFunctionExpression(arrow) => {
self.emit(
FnKind::Arrow,
prop_name,
arrow.span,
arrow.params.items.len(),
);
}
Expression::FunctionExpression(func) => {
self.emit(
FnKind::Regular {
is_async: func.r#async,
is_generator: func.generator,
},
prop_name,
func.span,
func.params.items.len(),
);
}
Expression::ConditionalExpression(cond) => {
self.scan_conditional(cond, prop_name);
}
Expression::LogicalExpression(logical) => {
self.scan_logical(logical, prop_name);
}
Expression::ParenthesizedExpression(paren) => {
self.scan_parenthesized(paren, prop_name);
}
_ => {}
}
}
fn scan_conditional(&mut self, cond: &ConditionalExpression<'_>, prop_name: &str) {
self.scan_for_inline_fn(&cond.consequent, prop_name);
self.scan_for_inline_fn(&cond.alternate, prop_name);
}
fn scan_logical(&mut self, logical: &LogicalExpression<'_>, prop_name: &str) {
self.scan_for_inline_fn(&logical.left, prop_name);
self.scan_for_inline_fn(&logical.right, prop_name);
}
fn scan_parenthesized(&mut self, paren: &ParenthesizedExpression<'_>, prop_name: &str) {
self.scan_for_inline_fn(&paren.expression, prop_name);
}
fn emit(&mut self, kind: FnKind, prop_name: &str, span: Span, param_count: usize) {
let (line, col) = offset_to_line_col(self.source_text, span.start);
let suggestion = build_suggestion(kind, prop_name, param_count);
let fn_desc = match kind {
FnKind::Arrow => "Inline arrow function",
FnKind::Regular { .. } => "Inline function expression",
};
self.issues.push(Issue {
rule: "no_inline_jsx_fn".to_string(),
message: format!(
"{fn_desc} in '{prop_name}' prop creates a new reference on every render. {suggestion}"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Medium,
source: crate::rules::IssueSource::ReactPerfAnalyzer,
category: crate::rules::IssueCategory::Performance,
});
}
}
#[derive(Copy, Clone)]
enum FnKind {
Arrow,
Regular { is_async: bool, is_generator: bool },
}
fn extract_prop_name(name: &JSXAttributeName<'_>) -> String {
match name {
JSXAttributeName::Identifier(id) => id.name.to_string(),
JSXAttributeName::NamespacedName(ns) => {
format!("{}:{}", ns.namespace.name, ns.name.name)
}
}
}
fn is_memoized(expr: &Expression<'_>) -> bool {
if let Expression::CallExpression(call) = expr {
return is_memo_callee(&call.callee);
}
false
}
fn is_memo_callee(callee: &Expression<'_>) -> bool {
const MEMO_HOOKS: &[&str] = &["useCallback", "useMemo"];
match callee {
Expression::Identifier(id) => MEMO_HOOKS.contains(&id.name.as_str()),
Expression::StaticMemberExpression(member) => {
MEMO_HOOKS.contains(&member.property.name.as_str())
}
_ => false,
}
}
fn build_suggestion(kind: FnKind, prop_name: &str, param_count: usize) -> String {
let is_event_handler = prop_name.starts_with("on")
&& prop_name.len() > 2
&& prop_name
.chars()
.nth(2)
.map(|c| c.is_uppercase())
.unwrap_or(false);
match kind {
FnKind::Arrow if is_event_handler => {
if param_count == 0 {
format!(
"Extract to a named handler or wrap with useCallback: \
`const handle{} = useCallback(() => {{ ... }}, [deps])`",
capitalize(prop_name.trim_start_matches("on"))
)
} else {
format!(
"Wrap with useCallback to stabilize the reference: \
`const handle{} = useCallback((e) => {{ ... }}, [deps])`",
capitalize(prop_name.trim_start_matches("on"))
)
}
}
FnKind::Arrow => {
"Extract to a stable variable outside the component or wrap with useCallback"
.to_string()
}
FnKind::Regular {
is_async,
is_generator,
} => {
let prefix = match (is_async, is_generator) {
(true, _) => "async ",
(_, true) => "function* ",
_ => "",
};
format!(
"Convert to an arrow function and wrap with useCallback, \
or extract as a {prefix}named function outside the component"
)
}
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}