Files
millionco__react-doctor/native/oxlint/rules/query-no-mutation-in-effect-as-read.rs
2026-09-02 01:29:54 -07:00

1189 lines
44 KiB
Rust

use oxc_ast::{
AstKind,
ast::{BindingPattern, Expression, ObjectPropertyKind},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::GetSpan;
use oxc_syntax::operator::{BinaryOperator, UnaryOperator};
use rustc_hash::FxHashSet;
use crate::{
AstNode,
context::{ContextHost, LintContext},
rule::Rule,
};
const ACKNOWLEDGEMENT_FIELD_NAMES: [&str; 7] = [
"code", "error", "errors", "message", "ok", "status", "success",
];
const EFFECT_HOOK_NAMES: [&str; 2] = ["useEffect", "useLayoutEffect"];
const READ_INTENT_WORDS: [&str; 11] = [
"check", "fetch", "find", "get", "list", "load", "lookup", "query", "read", "retrieve",
"search",
];
const WRITE_INTENT_WORDS: [&str; 16] = [
"add", "create", "delete", "insert", "mutate", "patch", "post", "put", "remove", "save",
"send", "set", "submit", "update", "upload", "write",
];
const TANSTACK_QUERY_MODULE_SOURCES: [&str; 2] = ["@tanstack/react-query", "react-query"];
const MESSAGE: &str = "This `useMutation` call is driven from an effect and its response is consumed as read data, so the result is neither cached nor deduplicated like a query.";
#[derive(Clone, Copy)]
struct StatusTarget {
symbol_id: oxc_semantic::SymbolId,
property_name: Option<&'static str>,
source_property_name: &'static str,
}
#[derive(Clone)]
struct EffectFunctionPath {
function_id: oxc_semantic::NodeId,
invocation_node_ids: Vec<oxc_semantic::NodeId>,
function_ids: Vec<oxc_semantic::NodeId>,
}
#[derive(Debug, Default, Clone)]
pub struct QueryNoMutationInEffectAsRead;
declare_oxc_lint!(
/// Disallow using an effect-driven mutation as a read.
QueryNoMutationInEffectAsRead,
react_doctor_native,
correctness,
version = "0.1.0",
short_description = "Disallow using an effect-driven mutation as a read.",
);
impl Rule for QueryNoMutationInEffectAsRead {
fn should_run(&self, ctx: &ContextHost) -> bool {
!is_test_noise_file(ctx)
}
fn run_once<'a>(&self, ctx: &LintContext<'a>) {
let effect_function_paths = collect_effect_function_paths(ctx);
if effect_function_paths.is_empty() {
return;
}
for declaration_node in ctx.nodes().iter() {
let AstKind::VariableDeclarator(declarator) = declaration_node.kind() else {
continue;
};
let Some(Expression::CallExpression(initializer)) = declarator
.init
.as_ref()
.map(Expression::get_inner_expression)
else {
continue;
};
if !module_api_path_matches(
&initializer.callee,
&["useMutation"],
&TANSTACK_QUERY_MODULE_SOURCES,
false,
ctx,
) || !declarator_has_read_intent(declarator, initializer, ctx)
{
continue;
}
let result_symbol_ids = mutation_result_symbol_ids(declarator, ctx);
let method_symbol_ids = mutation_method_symbol_ids(declarator, &result_symbol_ids, ctx);
let status_targets = mutation_status_targets(declarator, &result_symbol_ids, ctx);
let has_shared_consumer =
mutation_data_is_consumed(declarator, &result_symbol_ids, ctx)
|| options_consume_response(
initializer
.arguments
.first()
.and_then(|argument| argument.as_expression()),
ctx,
);
for call_node in ctx.nodes().iter() {
let AstKind::CallExpression(call_expression) = call_node.kind() else {
continue;
};
let Some(function_node) = crate::ast_util::get_enclosing_function(call_node, ctx)
else {
continue;
};
if !is_mutation_method_call(
call_expression,
&result_symbol_ids,
&method_symbol_ids,
ctx,
) {
continue;
}
let has_active_effect_path = effect_function_paths.iter().any(|path| {
path.function_id == function_node.id()
&& !path_has_run_once_latch(call_node, ctx)
&& !path_has_status_guard(call_node, &status_targets, ctx)
&& path.invocation_node_ids.iter().all(|node_id| {
let invocation_node = ctx.nodes().get_node(*node_id);
!path_has_run_once_latch(invocation_node, ctx)
&& !path_has_status_guard(invocation_node, &status_targets, ctx)
})
});
if !has_active_effect_path {
continue;
}
if !has_shared_consumer
&& !mutation_call_result_is_consumed(call_node, call_expression, ctx)
{
continue;
}
ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(initializer.span));
break;
}
}
}
}
fn collect_effect_function_paths(ctx: &LintContext<'_>) -> Vec<EffectFunctionPath> {
let mut effect_function_paths = Vec::new();
for node in ctx.nodes().iter() {
let AstKind::CallExpression(call_expression) = node.kind() else {
continue;
};
if !is_react_hook_call(call_expression, &EFFECT_HOOK_NAMES, ctx) {
continue;
}
let Some(callback_expression) = call_expression
.arguments
.first()
.and_then(oxc_ast::ast::Argument::as_expression)
else {
continue;
};
let mut callback_ids = Vec::new();
collect_selected_effect_callback_ids(callback_expression, ctx, &mut callback_ids);
effect_function_paths.extend(callback_ids.into_iter().map(|callback_id| {
EffectFunctionPath {
function_id: callback_id,
invocation_node_ids: Vec::new(),
function_ids: vec![callback_id],
}
}));
}
let mut pending_function_paths = effect_function_paths.clone();
while let Some(function_path) = pending_function_paths.pop() {
for candidate in ctx.nodes().iter() {
if local_callback_nearest_function_id(candidate.id(), ctx)
!= Some(function_path.function_id)
{
continue;
}
let AstKind::CallExpression(call_expression) = candidate.kind() else {
continue;
};
if let Some(called_function_id) =
exact_local_callback_function_id(&call_expression.callee, ctx, &mut Vec::new())
&& !function_path.function_ids.contains(&called_function_id)
{
let mut called_function_path = function_path.clone();
called_function_path.function_id = called_function_id;
called_function_path
.invocation_node_ids
.push(candidate.id());
called_function_path.function_ids.push(called_function_id);
effect_function_paths.push(called_function_path.clone());
pending_function_paths.push(called_function_path);
}
}
}
effect_function_paths
}
fn collect_selected_effect_callback_ids<'a>(
expression: &Expression<'a>,
ctx: &LintContext<'a>,
callback_ids: &mut Vec<oxc_semantic::NodeId>,
) {
match expression.get_inner_expression() {
Expression::ConditionalExpression(conditional) => {
collect_selected_effect_callback_ids(&conditional.consequent, ctx, callback_ids);
collect_selected_effect_callback_ids(&conditional.alternate, ctx, callback_ids);
}
Expression::LogicalExpression(logical) => {
collect_selected_effect_callback_ids(&logical.right, ctx, callback_ids);
if logical.operator != oxc_syntax::operator::LogicalOperator::And {
collect_selected_effect_callback_ids(&logical.left, ctx, callback_ids);
}
}
Expression::SequenceExpression(sequence) => {
if let Some(last_expression) = sequence.expressions.last() {
collect_selected_effect_callback_ids(last_expression, ctx, callback_ids);
}
}
expression => {
let Some((_, callback_span)) = resolve_local_react_callback(expression, ctx) else {
return;
};
if let Some(callback_id) = ctx.nodes().iter().find_map(|candidate| {
(candidate.span() == callback_span
&& matches!(
candidate.kind(),
AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
))
.then_some(candidate.id())
}) {
callback_ids.push(callback_id);
}
}
}
}
fn declarator_has_read_intent<'a>(
declarator: &oxc_ast::ast::VariableDeclarator<'_>,
initializer: &'a oxc_ast::ast::CallExpression<'a>,
ctx: &LintContext<'a>,
) -> bool {
let mut names = Vec::new();
if let BindingPattern::BindingIdentifier(identifier) = &declarator.id {
names.push(identifier.name.to_string());
}
for property_name in ["mutate", "mutateAsync"] {
for symbol_id in binding_symbols_for_property(&declarator.id, property_name) {
names.push(ctx.scoping().symbol_name(symbol_id).to_string());
}
}
if let Some(name) = mutation_function_name(initializer, ctx) {
names.push(name.to_string());
}
!names.iter().any(|name| has_write_intent_name(name))
&& names.iter().any(|name| has_read_intent_name(name))
}
fn mutation_function_name<'a>(
initializer: &'a oxc_ast::ast::CallExpression<'a>,
ctx: &LintContext<'a>,
) -> Option<&'a str> {
let argument = initializer.arguments.first()?.as_expression()?;
if let Some(options) = resolve_options_object(argument, ctx, &mut FxHashSet::default()) {
let mutation_function = options.properties.iter().find_map(|property| {
let ObjectPropertyKind::ObjectProperty(property) = property else {
return None;
};
(property.key.static_name().as_deref() == Some("mutationFn")).then_some(&property.value)
})?;
return expression_binding_name(mutation_function, ctx);
}
expression_binding_name(argument, ctx)
}
fn expression_binding_name<'a>(
expression: &'a Expression<'a>,
ctx: &LintContext<'a>,
) -> Option<&'a str> {
match expression.get_inner_expression() {
Expression::Identifier(identifier) => {
let symbol_id = ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()?;
let declaration = ctx.symbol_declaration(symbol_id);
matches!(
declaration.kind(),
AstKind::Function(_) | AstKind::VariableDeclarator(_)
)
.then_some(identifier.name.as_str())
}
expression => expression
.as_member_expression()
.and_then(oxc_ast::ast::MemberExpression::static_property_name),
}
}
fn has_read_intent_name(name: &str) -> bool {
let words = tokenize_query_identifier_words(name);
if words
.windows(2)
.any(|words| words[0] == "check" && matches!(words[1].as_str(), "in" | "out"))
{
return false;
}
words.iter().enumerate().any(|(index, word)| {
(word == "list" && index == 0)
|| (word != "list" && READ_INTENT_WORDS.contains(&word.as_str()))
})
}
fn has_write_intent_name(name: &str) -> bool {
tokenize_query_identifier_words(name)
.iter()
.any(|word| WRITE_INTENT_WORDS.contains(&word.as_str()))
}
fn tokenize_query_identifier_words(name: &str) -> Vec<String> {
let characters = name.chars().collect::<Vec<_>>();
let mut words = Vec::new();
let mut index = 0;
while index < characters.len() {
if !characters[index].is_ascii_alphanumeric() {
index += 1;
continue;
}
let start = index;
if characters[index].is_ascii_digit() {
index += 1;
while index < characters.len() && characters[index].is_ascii_digit() {
index += 1;
}
} else if characters[index].is_ascii_uppercase() {
index += 1;
while index < characters.len() && characters[index].is_ascii_uppercase() {
if index + 1 < characters.len() && characters[index + 1].is_ascii_lowercase() {
break;
}
index += 1;
}
while index < characters.len() && characters[index].is_ascii_lowercase() {
index += 1;
}
} else {
index += 1;
while index < characters.len() && characters[index].is_ascii_lowercase() {
index += 1;
}
}
words.push(
characters[start..index]
.iter()
.collect::<String>()
.to_ascii_lowercase(),
);
}
words
}
fn mutation_result_symbol_ids(
declarator: &oxc_ast::ast::VariableDeclarator<'_>,
ctx: &LintContext<'_>,
) -> FxHashSet<oxc_semantic::SymbolId> {
let BindingPattern::BindingIdentifier(binding) = &declarator.id else {
return FxHashSet::default();
};
collect_const_alias_symbol_ids(binding.symbol_id(), ctx)
}
fn mutation_method_symbol_ids(
declarator: &oxc_ast::ast::VariableDeclarator<'_>,
result_symbol_ids: &FxHashSet<oxc_semantic::SymbolId>,
ctx: &LintContext<'_>,
) -> FxHashSet<oxc_semantic::SymbolId> {
let mut method_symbol_ids = FxHashSet::default();
for property_name in ["mutate", "mutateAsync"] {
for symbol_id in binding_symbols_for_property(&declarator.id, property_name) {
method_symbol_ids.extend(collect_const_alias_symbol_ids(symbol_id, ctx));
}
}
for result_symbol_id in result_symbol_ids {
for reference in ctx.scoping().get_resolved_references(*result_symbol_id) {
let reference_node = ctx.nodes().get_node(reference.node_id());
let reference_root = transparent_expression_root(reference_node, ctx);
let member_node = ctx.nodes().parent_node(reference_root.id());
if let AstKind::VariableDeclarator(alias_declarator) = member_node.kind()
&& alias_declarator
.init
.as_ref()
.is_some_and(|initializer| initializer.span() == reference_root.span())
{
for property_name in ["mutate", "mutateAsync"] {
for symbol_id in
binding_symbols_for_property(&alias_declarator.id, property_name)
{
method_symbol_ids.extend(collect_const_alias_symbol_ids(symbol_id, ctx));
}
}
continue;
}
let Some(member_expression) = member_node.kind().as_member_expression_kind() else {
continue;
};
if !member_expression
.static_property_name()
.is_some_and(|name| matches!(name.as_ref(), "mutate" | "mutateAsync"))
{
continue;
}
let member_root = transparent_expression_root(member_node, ctx);
let parent = ctx.nodes().parent_node(member_root.id());
let AstKind::VariableDeclarator(alias_declarator) = parent.kind() else {
continue;
};
if alias_declarator
.init
.as_ref()
.is_none_or(|initializer| initializer.span() != member_root.span())
{
continue;
}
if let Some(binding) = alias_declarator.id.get_binding_identifier() {
method_symbol_ids.extend(collect_const_alias_symbol_ids(binding.symbol_id(), ctx));
}
}
}
method_symbol_ids
}
fn is_mutation_method_call(
call_expression: &oxc_ast::ast::CallExpression<'_>,
result_symbol_ids: &FxHashSet<oxc_semantic::SymbolId>,
method_symbol_ids: &FxHashSet<oxc_semantic::SymbolId>,
ctx: &LintContext<'_>,
) -> bool {
match call_expression.callee.get_inner_expression() {
Expression::Identifier(identifier) => ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()
.is_some_and(|symbol_id| method_symbol_ids.contains(&symbol_id)),
expression => expression.as_member_expression().is_some_and(|member| {
member
.static_property_name()
.is_some_and(|name| matches!(name.as_ref(), "mutate" | "mutateAsync"))
&& matches!(member.object().get_inner_expression(), Expression::Identifier(identifier) if ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()
.is_some_and(|symbol_id| result_symbol_ids.contains(&symbol_id)))
}),
}
}
fn collect_const_alias_symbol_ids(
root_symbol_id: oxc_semantic::SymbolId,
ctx: &LintContext<'_>,
) -> FxHashSet<oxc_semantic::SymbolId> {
let mut symbol_ids = FxHashSet::from_iter([root_symbol_id]);
let mut pending_symbol_ids = vec![root_symbol_id];
while let Some(symbol_id) = pending_symbol_ids.pop() {
for reference in ctx.scoping().get_resolved_references(symbol_id) {
let reference_root =
transparent_expression_root(ctx.nodes().get_node(reference.node_id()), ctx);
let parent = ctx.nodes().parent_node(reference_root.id());
let AstKind::VariableDeclarator(declarator) = parent.kind() else {
continue;
};
let declaration = ctx.nodes().parent_node(parent.id());
if !matches!(declaration.kind(), AstKind::VariableDeclaration(variable_declaration) if variable_declaration.kind.is_const())
|| declarator
.init
.as_ref()
.is_none_or(|initializer| initializer.span() != reference_root.span())
{
continue;
}
let Some(binding) = declarator.id.get_binding_identifier() else {
continue;
};
if symbol_ids.insert(binding.symbol_id()) {
pending_symbol_ids.push(binding.symbol_id());
}
}
}
symbol_ids
}
fn binding_symbols_for_property(
pattern: &BindingPattern<'_>,
property_name: &str,
) -> Vec<oxc_semantic::SymbolId> {
let BindingPattern::ObjectPattern(pattern) = pattern else {
return Vec::new();
};
pattern
.properties
.iter()
.filter(|property| property.key.static_name().as_deref() == Some(property_name))
.filter_map(|property| {
property
.value
.get_binding_identifier()
.map(|binding| binding.symbol_id())
})
.collect()
}
fn mutation_data_is_consumed(
declarator: &oxc_ast::ast::VariableDeclarator<'_>,
result_symbol_ids: &FxHashSet<oxc_semantic::SymbolId>,
ctx: &LintContext<'_>,
) -> bool {
for symbol_id in binding_symbols_for_property(&declarator.id, "data") {
if symbol_has_consumer_read(symbol_id, ctx, &mut FxHashSet::default()) {
return true;
}
}
for result_symbol_id in result_symbol_ids {
for reference in ctx.scoping().get_resolved_references(*result_symbol_id) {
let reference_node = ctx.nodes().get_node(reference.node_id());
let reference_root = transparent_expression_root(reference_node, ctx);
let parent = ctx.nodes().parent_node(reference_root.id());
if let Some(member) = parent.kind().as_member_expression_kind()
&& member.static_property_name().as_deref() == Some("data")
&& response_expression_is_consumed(parent, ctx, &mut FxHashSet::default())
{
return true;
}
let AstKind::VariableDeclarator(alias_declarator) = parent.kind() else {
continue;
};
if alias_declarator
.init
.as_ref()
.is_none_or(|initializer| initializer.span() != reference_root.span())
{
continue;
}
for data_symbol_id in binding_symbols_for_property(&alias_declarator.id, "data") {
if symbol_has_consumer_read(data_symbol_id, ctx, &mut FxHashSet::default()) {
return true;
}
}
}
}
false
}
fn symbol_has_consumer_read(
symbol_id: oxc_semantic::SymbolId,
ctx: &LintContext<'_>,
visited_symbol_ids: &mut FxHashSet<oxc_semantic::SymbolId>,
) -> bool {
if !visited_symbol_ids.insert(symbol_id) {
return false;
}
ctx.scoping()
.get_resolved_references(symbol_id)
.any(|reference| {
response_expression_is_consumed(
ctx.nodes().get_node(reference.node_id()),
ctx,
visited_symbol_ids,
)
})
}
fn response_expression_is_consumed<'a>(
expression_node: &AstNode<'a>,
ctx: &LintContext<'a>,
visited_symbol_ids: &mut FxHashSet<oxc_semantic::SymbolId>,
) -> bool {
if is_effect_dependency_reference(expression_node, ctx)
|| is_guard_only_reference(expression_node, ctx)
{
return false;
}
let mut expression_root = transparent_expression_root(expression_node, ctx);
loop {
let parent = ctx.nodes().parent_node(expression_root.id());
let propagates = match parent.kind() {
AstKind::SequenceExpression(sequence) => sequence
.expressions
.last()
.is_some_and(|expression| expression.span() == expression_root.span()),
AstKind::ConditionalExpression(conditional) => {
conditional.consequent.span() == expression_root.span()
|| conditional.alternate.span() == expression_root.span()
}
AstKind::LogicalExpression(logical) => logical.right.span() == expression_root.span(),
_ => false,
};
if !propagates {
break;
}
expression_root = transparent_expression_root(parent, ctx);
}
let parent = ctx.nodes().parent_node(expression_root.id());
if let Some(member) = parent.kind().as_member_expression_kind() {
return member
.static_property_name()
.is_none_or(|name| !ACKNOWLEDGEMENT_FIELD_NAMES.contains(&name.as_ref()));
}
match parent.kind() {
AstKind::SequenceExpression(sequence)
if sequence
.expressions
.last()
.is_none_or(|expression| expression.span() != expression_root.span()) =>
{
false
}
AstKind::UnaryExpression(unary) if unary.operator == UnaryOperator::Void => false,
AstKind::ExpressionStatement(_) => false,
AstKind::VariableDeclarator(declarator)
if declarator
.init
.as_ref()
.is_some_and(|initializer| initializer.span() == expression_root.span()) =>
{
match &declarator.id {
BindingPattern::ObjectPattern(pattern) => object_pattern_consumes_response(pattern),
BindingPattern::BindingIdentifier(binding) => {
symbol_has_consumer_read(binding.symbol_id(), ctx, visited_symbol_ids)
}
_ => true,
}
}
_ => true,
}
}
fn is_effect_dependency_reference(node: &AstNode<'_>, ctx: &LintContext<'_>) -> bool {
for parent in ctx.nodes().ancestors(node.id()) {
if matches!(
parent.kind(),
AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
) {
return false;
}
let AstKind::ArrayExpression(array) = parent.kind() else {
continue;
};
let array_root = transparent_expression_root(parent, ctx);
let call_node = ctx.nodes().parent_node(array_root.id());
let AstKind::CallExpression(call) = call_node.kind() else {
return false;
};
return call
.arguments
.get(1)
.and_then(|argument| argument.as_expression())
.is_some_and(|argument| argument.span() == array_root.span())
&& is_react_hook_call(call, &EFFECT_HOOK_NAMES, ctx)
&& !array.elements.is_empty();
}
false
}
fn is_guard_only_reference<'a>(node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool {
let root = transparent_expression_root(node, ctx);
let parent = ctx.nodes().parent_node(root.id());
match parent.kind() {
AstKind::UnaryExpression(unary) => unary.operator == UnaryOperator::LogicalNot,
AstKind::CallExpression(call) => {
call.arguments
.first()
.and_then(|argument| argument.as_expression())
.is_some_and(|argument| argument.span() == root.span())
&& matches!(call.callee.get_inner_expression(), Expression::Identifier(identifier) if identifier.name == "Boolean" && ctx.scoping().get_reference(identifier.reference_id()).symbol_id().is_none())
}
AstKind::LogicalExpression(logical) => {
if logical.left.span() == root.span()
&& logical.operator == oxc_syntax::operator::LogicalOperator::And
{
true
} else {
is_guard_only_reference(parent, ctx)
}
}
AstKind::ConditionalExpression(conditional) if conditional.test.span() == root.span() => {
true
}
AstKind::ConditionalExpression(_) | AstKind::SequenceExpression(_) => {
is_guard_only_reference(parent, ctx)
}
AstKind::IfStatement(statement) => statement.test.span() == root.span(),
AstKind::WhileStatement(statement) => statement.test.span() == root.span(),
AstKind::DoWhileStatement(statement) => statement.test.span() == root.span(),
AstKind::ForStatement(statement) => statement
.test
.as_ref()
.is_some_and(|test| test.span() == root.span()),
AstKind::BinaryExpression(binary) if is_equality_operator(binary.operator) => {
let other = if binary.left.span() == root.span() {
&binary.right
} else {
&binary.left
};
is_nullish_expression(other)
}
_ => false,
}
}
fn object_pattern_consumes_response(pattern: &oxc_ast::ast::ObjectPattern<'_>) -> bool {
pattern.properties.iter().any(|property| {
property
.key
.static_name()
.is_none_or(|name| !ACKNOWLEDGEMENT_FIELD_NAMES.contains(&name.as_ref()))
}) || pattern.rest.is_some()
}
fn resolve_options_object<'a>(
expression: &'a Expression<'a>,
ctx: &LintContext<'a>,
visited_symbol_ids: &mut FxHashSet<oxc_semantic::SymbolId>,
) -> Option<&'a oxc_ast::ast::ObjectExpression<'a>> {
match expression.get_inner_expression() {
Expression::ObjectExpression(object) => Some(object),
Expression::Identifier(identifier) => {
let symbol_id = ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()?;
if !visited_symbol_ids.insert(symbol_id) {
return None;
}
let declaration = ctx.symbol_declaration(symbol_id);
let AstKind::VariableDeclarator(declarator) = declaration.kind() else {
return None;
};
let initializer = declarator.init.as_ref()?;
resolve_options_object(initializer, ctx, visited_symbol_ids)
}
_ => None,
}
}
fn options_consume_response<'a>(
expression: Option<&'a Expression<'a>>,
ctx: &LintContext<'a>,
) -> bool {
let Some(expression) = expression else {
return false;
};
let Some(options) = resolve_options_object(expression, ctx, &mut FxHashSet::default()) else {
return false;
};
options.properties.iter().any(|property| {
let ObjectPropertyKind::ObjectProperty(property) = property else {
return false;
};
matches!(
property.key.static_name().as_deref(),
Some("onSuccess" | "onSettled")
) && handler_consumes_response(&property.value, ctx)
})
}
fn handler_consumes_response<'a>(expression: &Expression<'a>, ctx: &LintContext<'a>) -> bool {
let Some((_, function_span)) = resolve_local_react_callback(expression, ctx) else {
return false;
};
let Some(function_node) = ctx
.nodes()
.iter()
.find(|candidate| candidate.span() == function_span)
else {
return false;
};
let parameter = match function_node.kind() {
AstKind::Function(function) => function.params.items.first().map(|item| &item.pattern),
AstKind::ArrowFunctionExpression(function) => {
function.params.items.first().map(|item| &item.pattern)
}
_ => None,
};
match parameter {
Some(BindingPattern::BindingIdentifier(binding)) => {
symbol_has_consumer_read(binding.symbol_id(), ctx, &mut FxHashSet::default())
}
Some(BindingPattern::ObjectPattern(pattern)) => object_pattern_consumes_response(pattern),
_ => false,
}
}
fn mutation_call_result_is_consumed<'a>(
call_node: &AstNode<'a>,
call_expression: &'a oxc_ast::ast::CallExpression<'a>,
ctx: &LintContext<'a>,
) -> bool {
let call_root = transparent_expression_root(call_node, ctx);
let parent = ctx.nodes().parent_node(call_root.id());
if matches!(parent.kind(), AstKind::AwaitExpression(_)) {
return response_expression_is_consumed(parent, ctx, &mut FxHashSet::default());
}
if let Some(member) = parent.kind().as_member_expression_kind()
&& member.static_property_name().as_deref() == Some("then")
{
let member_root = transparent_expression_root(parent, ctx);
let then_node = ctx.nodes().parent_node(member_root.id());
if let AstKind::CallExpression(then_call) = then_node.kind()
&& let Some(handler) = then_call
.arguments
.first()
.and_then(|argument| argument.as_expression())
&& handler_consumes_response(handler, ctx)
{
return true;
}
}
options_consume_response(
call_expression
.arguments
.get(1)
.and_then(|argument| argument.as_expression()),
ctx,
)
}
fn mutation_status_targets(
declarator: &oxc_ast::ast::VariableDeclarator<'_>,
result_symbol_ids: &FxHashSet<oxc_semantic::SymbolId>,
ctx: &LintContext<'_>,
) -> Vec<StatusTarget> {
let mut targets = Vec::new();
if !result_symbol_ids.is_empty() {
for symbol_id in result_symbol_ids {
for property_name in ["data", "isSuccess", "status"] {
targets.push(StatusTarget {
symbol_id: *symbol_id,
property_name: Some(property_name),
source_property_name: property_name,
});
}
}
return targets;
}
for property_name in ["data", "isSuccess", "status"] {
for symbol_id in binding_symbols_for_property(&declarator.id, property_name) {
for alias_symbol_id in collect_const_alias_symbol_ids(symbol_id, ctx) {
targets.push(StatusTarget {
symbol_id: alias_symbol_id,
property_name: None,
source_property_name: property_name,
});
}
}
}
targets
}
fn path_has_status_guard<'a>(
call_node: &AstNode<'a>,
targets: &[StatusTarget],
ctx: &LintContext<'a>,
) -> bool {
for ancestor in ctx.nodes().ancestors(call_node.id()) {
if matches!(
ancestor.kind(),
AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
) {
break;
}
if let AstKind::IfStatement(statement) = ancestor.kind()
&& statement
.consequent
.span()
.contains_inclusive(call_node.span())
&& targets
.iter()
.any(|target| status_test_matches(&statement.test, target, false, ctx))
{
return true;
}
}
let Some(function_node) = crate::ast_util::get_enclosing_function(call_node, ctx) else {
return false;
};
ctx.nodes().iter().any(|candidate| {
let AstKind::IfStatement(statement) = candidate.kind() else {
return false;
};
local_callback_nearest_function_id(candidate.id(), ctx) == Some(function_node.id())
&& candidate.span().start < call_node.span().start
&& query_mutation_statement_precedes_path_node(candidate, call_node, ctx)
&& statement_always_exits(&statement.consequent)
&& targets
.iter()
.any(|target| status_test_matches(&statement.test, target, true, ctx))
})
}
fn query_mutation_statement_precedes_path_node<'a>(
statement: &AstNode<'a>,
path_node: &AstNode<'a>,
ctx: &LintContext<'a>,
) -> bool {
ctx.nodes().ancestors(path_node.id()).any(|ancestor| {
matches!(ancestor.kind(), AstKind::BlockStatement(_))
&& ctx.nodes().parent_node(statement.id()).id() == ancestor.id()
&& statement.span().end <= path_node.span().start
})
}
fn status_test_matches(
expression: &Expression<'_>,
target: &StatusTarget,
positive: bool,
ctx: &LintContext<'_>,
) -> bool {
let expression = expression.get_inner_expression();
if let Expression::UnaryExpression(unary) = expression
&& unary.operator == UnaryOperator::LogicalNot
{
return status_test_matches(&unary.argument, target, !positive, ctx);
}
if expression_matches_status_target(expression, target, ctx) {
return positive && target.source_property_name == "isSuccess";
}
let Expression::BinaryExpression(binary) = expression else {
return false;
};
let (other, matches_target) = if expression_matches_status_target(&binary.left, target, ctx) {
(&binary.right, true)
} else if expression_matches_status_target(&binary.right, target, ctx) {
(&binary.left, true)
} else {
(&binary.left, false)
};
if !matches_target {
return false;
}
let is_equality = matches!(
binary.operator,
BinaryOperator::Equality | BinaryOperator::StrictEquality
);
let is_inequality = matches!(
binary.operator,
BinaryOperator::Inequality | BinaryOperator::StrictInequality
);
match target.source_property_name {
"data" => {
if positive {
is_inequality && is_nullish_expression(other)
} else {
is_equality && is_nullish_expression(other)
}
}
"status" => {
static_string_value(other) == Some("success")
&& if positive { is_equality } else { is_inequality }
}
_ => static_boolean_value(other).is_some_and(|value| {
if positive {
(is_equality && value) || (is_inequality && !value)
} else {
(is_equality && !value) || (is_inequality && value)
}
}),
}
}
fn expression_matches_status_target(
expression: &Expression<'_>,
target: &StatusTarget,
ctx: &LintContext<'_>,
) -> bool {
match expression.get_inner_expression() {
Expression::Identifier(identifier) => target.property_name.is_none()
&& ctx.scoping().get_reference(identifier.reference_id()).symbol_id() == Some(target.symbol_id),
expression => expression.as_member_expression().is_some_and(|member| {
target.property_name.is_some_and(|property_name| member.static_property_name() == Some(property_name))
&& matches!(member.object().get_inner_expression(), Expression::Identifier(identifier) if ctx.scoping().get_reference(identifier.reference_id()).symbol_id() == Some(target.symbol_id))
}),
}
}
fn path_has_run_once_latch<'a>(call_node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool {
for node in ctx.nodes().iter() {
let AstKind::VariableDeclarator(declarator) = node.kind() else {
continue;
};
let Some(binding) = declarator.id.get_binding_identifier() else {
continue;
};
let Some(Expression::CallExpression(initializer)) = declarator
.init
.as_ref()
.map(Expression::get_inner_expression)
else {
continue;
};
if !is_react_api_call(initializer, "useRef", ctx)
|| ref_symbol_has_resetting_write(binding.symbol_id(), ctx)
{
continue;
}
let ref_symbol_id = binding.symbol_id();
let mut guard_start = None;
for candidate in ctx.nodes().iter() {
let AstKind::IfStatement(statement) = candidate.kind() else {
continue;
};
let is_early_exit_guard = candidate.span().start < call_node.span().start
&& node_dominates_node(candidate, call_node, ctx)
&& statement_always_exits(&statement.consequent)
&& ref_test_matches(&statement.test, ref_symbol_id, true, ctx);
let is_enclosing_guard = statement
.consequent
.span()
.contains_inclusive(call_node.span())
&& ref_test_matches(&statement.test, ref_symbol_id, false, ctx);
if is_early_exit_guard || is_enclosing_guard {
guard_start = Some(candidate.span().start);
break;
}
}
let Some(guard_start) = guard_start else {
continue;
};
if ctx.nodes().iter().any(|candidate| {
candidate.span().start > guard_start
&& candidate.span().start < call_node.span().start
&& node_dominates_node(candidate, call_node, ctx)
&& assigned_true_ref_symbol(candidate, ctx) == Some(ref_symbol_id)
}) {
return true;
}
}
false
}
fn ref_test_matches(
expression: &Expression<'_>,
ref_symbol_id: oxc_semantic::SymbolId,
positive: bool,
ctx: &LintContext<'_>,
) -> bool {
let expression = expression.get_inner_expression();
if let Expression::UnaryExpression(unary) = expression
&& unary.operator == UnaryOperator::LogicalNot
{
return ref_test_matches(&unary.argument, ref_symbol_id, !positive, ctx);
}
if ref_current_symbol(expression, ctx) == Some(ref_symbol_id) {
return positive;
}
let Expression::BinaryExpression(binary) = expression else {
return false;
};
let other = if ref_current_symbol(&binary.left, ctx) == Some(ref_symbol_id) {
&binary.right
} else if ref_current_symbol(&binary.right, ctx) == Some(ref_symbol_id) {
&binary.left
} else {
return false;
};
let Some(value) = static_boolean_value(other) else {
return false;
};
let value_when_true = if matches!(
binary.operator,
BinaryOperator::Equality | BinaryOperator::StrictEquality
) {
value
} else if matches!(
binary.operator,
BinaryOperator::Inequality | BinaryOperator::StrictInequality
) {
!value
} else {
return false;
};
value_when_true == positive
}
fn ref_current_symbol(
expression: &Expression<'_>,
ctx: &LintContext<'_>,
) -> Option<oxc_semantic::SymbolId> {
let member = expression.get_inner_expression().as_member_expression()?;
ref_current_member_symbol(member, ctx)
}
fn ref_current_member_symbol(
member: &oxc_ast::ast::MemberExpression<'_>,
ctx: &LintContext<'_>,
) -> Option<oxc_semantic::SymbolId> {
if member.static_property_name().as_deref() != Some("current") {
return None;
}
let Expression::Identifier(identifier) = member.object().get_inner_expression() else {
return None;
};
let symbol_id = ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()?;
let declaration = ctx.symbol_declaration(symbol_id);
let AstKind::VariableDeclarator(declarator) = declaration.kind() else {
return None;
};
let Expression::CallExpression(call) = declarator.init.as_ref()?.get_inner_expression() else {
return None;
};
is_react_api_call(call, "useRef", ctx).then_some(symbol_id)
}
fn assigned_true_ref_symbol(
node: &AstNode<'_>,
ctx: &LintContext<'_>,
) -> Option<oxc_semantic::SymbolId> {
let AstKind::AssignmentExpression(assignment) = node.kind() else {
return None;
};
if assignment.operator != oxc_syntax::operator::AssignmentOperator::Assign
|| static_boolean_value(&assignment.right) != Some(true)
{
return None;
}
let member = assignment.left.as_member_expression()?;
ref_current_member_symbol(member, ctx)
}
fn ref_symbol_has_resetting_write(
symbol_id: oxc_semantic::SymbolId,
ctx: &LintContext<'_>,
) -> bool {
ctx.scoping()
.get_resolved_references(symbol_id)
.any(|reference| {
let reference_root =
transparent_expression_root(ctx.nodes().get_node(reference.node_id()), ctx);
let member_node = ctx.nodes().parent_node(reference_root.id());
let Some(member) = member_node.kind().as_member_expression_kind() else {
return false;
};
if member.static_property_name().as_deref() != Some("current") {
return false;
}
let member_root = transparent_expression_root(member_node, ctx);
let parent = ctx.nodes().parent_node(member_root.id());
match parent.kind() {
AstKind::AssignmentExpression(assignment)
if assignment.left.span() == member_root.span() =>
{
assignment.operator != oxc_syntax::operator::AssignmentOperator::Assign
|| static_boolean_value(&assignment.right) != Some(true)
}
AstKind::UpdateExpression(_) => true,
_ => false,
}
})
}
fn static_boolean_value(expression: &Expression<'_>) -> Option<bool> {
match expression.get_inner_expression() {
Expression::BooleanLiteral(literal) => Some(literal.value),
_ => None,
}
}
fn static_string_value<'a>(expression: &'a Expression<'a>) -> Option<&'a str> {
match expression.get_inner_expression() {
Expression::StringLiteral(literal) => Some(literal.value.as_str()),
Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
.quasis
.first()
.and_then(|quasi| quasi.value.cooked.as_ref())
.map(|value| value.as_str()),
_ => None,
}
}
fn is_equality_operator(operator: BinaryOperator) -> bool {
matches!(
operator,
BinaryOperator::Equality
| BinaryOperator::Inequality
| BinaryOperator::StrictEquality
| BinaryOperator::StrictInequality
)
}