Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 166 additions & 4 deletions crates/oxc_linter/src/rules/react/rules_of_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ use std::borrow::Cow;

use oxc_ast::{
AstKind,
ast::{ArrowFunctionExpression, Function},
ast::{ArrowFunctionExpression, Expression, Function},
};
use oxc_cfg::{
ControlFlowGraph, EdgeType, ErrorEdgeKind, InstructionKind,
graph::{algo, visit::Control},
};
use oxc_macros::declare_oxc_lint;
use oxc_semantic::{AstNodes, NodeId};
use oxc_span::Span;
use oxc_syntax::operator::AssignmentOperator;

use crate::{
Expand Down Expand Up @@ -124,13 +125,12 @@ impl Rule for RulesOfHooks {
}

let cfg = ctx.cfg();
let nodes = ctx.nodes();

let span = call.span;
let hook_name =
call.callee_name().expect("We identify hooks using their names so it should be named.");

let nodes = ctx.nodes();

let is_use = is_react_function_call(call, "use");

let Some(parent_func) = parent_func(nodes, node) else {
Expand Down Expand Up @@ -249,7 +249,7 @@ impl Rule for RulesOfHooks {
return ctx.diagnostic(diagnostics::loop_hook(span, hook_name));
}

if has_conditional_path_accept_throw(cfg, parent_func, node) {
if has_conditional_path_accept_throw(cfg, nodes, parent_func, node) {
#[expect(clippy::needless_return)]
return ctx.diagnostic(diagnostics::conditional_hook(span, hook_name));
}
Expand All @@ -258,12 +258,21 @@ impl Rule for RulesOfHooks {

fn has_conditional_path_accept_throw(
cfg: &ControlFlowGraph,
nodes: &AstNodes<'_>,
from: &AstNode<'_>,
to: &AstNode<'_>,
) -> bool {
let from_graph_id = from.cfg_id();
let to_graph_id = to.cfg_id();
let graph = cfg.graph();

// Check if the hook call is part of a condition expression
// If the hook is used in a condition (like `if (useHook())`) rather than being conditionally called,
// then it's valid and we should return false
if is_hook_in_condition_expression(nodes, to) {
return false;
}
Comment on lines +268 to +274
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm while this solution works, i'm not convinced it's the ideal solution?

Ideally, i think we would change the cfg to represent that the test of an if statement is always represented as visited.

We would look to change the code inside visit_if_statement to something like:

iff --git a/crates/oxc_semantic/src/builder.rs b/crates/oxc_semantic/src/builder.rs
index 394307c1c..8c99f9391 100644
--- a/crates/oxc_semantic/src/builder.rs
+++ b/crates/oxc_semantic/src/builder.rs
@@ -1183,21 +1183,17 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
 
             cfg.add_edge(before_if_stmt_graph_ix, start_of_condition_graph_ix, EdgeType::Normal);
 
-            cfg.add_edge(after_consequent_stmt_graph_ix, after_if_graph_ix, EdgeType::Normal);
-
             cfg.add_edge(after_test_graph_ix, before_consequent_stmt_graph_ix, EdgeType::Jump);
 
+            cfg.add_edge(after_consequent_stmt_graph_ix, after_if_graph_ix, EdgeType::Normal);
+
             if let Some((start_of_alternate_stmt_graph_ix, after_alternate_stmt_graph_ix)) =
                 else_graph_ix
             {
-                cfg.add_edge(
-                    before_if_stmt_graph_ix,
-                    start_of_alternate_stmt_graph_ix,
-                    EdgeType::Normal,
-                );
+                cfg.add_edge(after_test_graph_ix, start_of_alternate_stmt_graph_ix, EdgeType::Jump);
                 cfg.add_edge(after_alternate_stmt_graph_ix, after_if_graph_ix, EdgeType::Normal);
             } else {
-                cfg.add_edge(before_if_stmt_graph_ix, after_if_graph_ix, EdgeType::Normal);
+                cfg.add_edge(after_test_graph_ix, after_if_graph_ix, EdgeType::Jump);
             }
         });
         /* cfg */

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is much much more elegant 👍, I will close my MR, thx for sharing

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any possible to give a bit more context why this can fix the issue?
I will look into the codes, still trying to understand the whole process in OXC.
Thanks for sharing again

Copy link
Contributor

@camc314 camc314 Mar 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah sure. firstly thanks for trying to fix it, we appreciate the contributions!

so the control flow graph logic of this codebase is (imo, i'm sure others may disagree 🙂) some of the most complex logical code.

From my understanding, the problem was that the "test" of the IfStatement, was being marked as conditionally visited (which is incorrect as the test is always evaluated).

by changing that logic in oxc_semantic, we changed the CFG, to represent that the test expression is always visited.

i'll try and put up a PR tomorrow, with a more detailed explanation on why those exact changes fix this issue

(on mobile atm)

thanks!


if graph
.edges(to_graph_id)
.any(|it| matches!(it.weight(), EdgeType::Error(ErrorEdgeKind::Explicit)))
Expand Down Expand Up @@ -327,6 +336,81 @@ fn has_conditional_path_accept_throw(
})
}

// Helper function to check if a hook call is part of a condition expression
fn is_hook_in_condition_expression(nodes: &AstNodes<'_>, node: &AstNode<'_>) -> bool {
let node_span = match node.kind() {
AstKind::CallExpression(call_expr) => call_expr.span,
_ => return false, // Not a call expression, so not a hook call
};

// Get the parent of the hook call
if let Some(parent_id) = nodes.parent_id(node.id()) {
let parent = nodes.get_node(parent_id);
match parent.kind() {
// Check if the hook is used directly in an if statement condition
AstKind::IfStatement(if_stmt) => {
// Get the span of the test expression
let test_span = match &if_stmt.test {
Expression::CallExpression(call) => call.span,
Expression::Identifier(ident) => ident.span,
Expression::UnaryExpression(unary) => unary.span,
Expression::LogicalExpression(logical) => logical.span,
Expression::BinaryExpression(binary) => binary.span,
_ => return false,
};
// Check if the hook call is within the test expression of the if statement
is_span_within(node_span, test_span)
}
// Check if the hook is used in a logical expression (&&, ||)
AstKind::LogicalExpression(logical_expr) => {
// We need to check if the hook is in the left side (condition part)
// of the logical expression, not the right side
if let Expression::CallExpression(call) = &logical_expr.left {
// If the hook is in the left side, it's a valid condition
is_span_within(node_span, call.span)
} else if let Expression::UnaryExpression(unary) = &logical_expr.left {
// Handle cases like !useHook() && something
if let Expression::CallExpression(call) = &unary.argument {
is_span_within(node_span, call.span)
} else {
false
}
} else {
false
}
}
// Check if the hook is used in a conditional expression (ternary)
AstKind::ConditionalExpression(cond_expr) => {
// Get the span of the test expression
let test_span = match &cond_expr.test {
Expression::CallExpression(call) => call.span,
Expression::Identifier(ident) => ident.span,
Expression::UnaryExpression(unary) => unary.span,
Expression::LogicalExpression(logical) => logical.span,
Expression::BinaryExpression(binary) => binary.span,
_ => return false,
};
// Check if the hook call is within the test part of the conditional expression
is_span_within(node_span, test_span)
}
// Check if the hook is used in a unary expression (like !)
AstKind::UnaryExpression(unary_expr) => {
// Check if the hook call is within the unary expression
is_span_within(node_span, unary_expr.span)
}
// For other cases, check if the parent is part of a condition
_ => is_hook_in_condition_expression(nodes, parent),
}
} else {
false
}
}

// Helper function to check if one span is within another
fn is_span_within(inner: Span, outer: Span) -> bool {
inner.start >= outer.start && inner.end <= outer.end
}

fn parent_func<'a>(nodes: &'a AstNodes<'a>, node: &AstNode) -> Option<&'a AstNode<'a>> {
nodes
.ancestor_ids(node.id())
Expand Down Expand Up @@ -444,6 +528,62 @@ fn test() {
useHook();
}
",


// Valid because hooks can be used in condition expressions beginning with a ternary condition
"
function ComponentWithConditionalHook() {
if (useHook() ? good() : bad()) {
check();
}
}
",

// Valid because hooks can be used in condition expressions
"
function Component() {
if (!useHasPermission()) {
return null;
}
return <Content />;
}
",
// Valid because hooks can be used in ternary condition expressions
"
function Component() {
return useHasPermission() ? <Content /> : null;
}
",
// Valid because hooks can be used in logical expressions (left side)
"
function Component() {
return useHasPermission() && <Content />;
}
",
// Valid because hooks can be used with negation in condition expressions
"
function Component() {
if (!useHasPermission()) {
return null;
}
return <Content />;
}
",
// Valid because hooks can be used in complex condition expressions
"
function Component() {
if (useHasPermission() && isAdmin()) {
return <AdminContent />;
}
return <Content />;
}
",
// Valid because hooks can be used in nested condition expressions
"
function Component() {
return (useHasPermission() && isAdmin()) ? <AdminContent /> : <Content />;
}
",
// Valid because components can use hooks.
"
function createComponentWithHook() {
Expand Down Expand Up @@ -968,6 +1108,28 @@ fn test() {
}
}
",
// Invalid because hooks are used in the right side of logical expressions
"
function useHook() {
a && useHook1();
b && useHook2();
}
",
// Invalid because hooks are used conditionally after a condition
"
function Component() {
if (condition) {
// This is invalid because the hook is called conditionally
useHook();
}
}
",
// Invalid because hooks are used in the right side of ternary expressions
"
function Component() {
condition ? useHook() : null;
}
",
// Invalid because hooks can only be called inside of a component.
// errors: [
// topLevelError('Hook.useState'),
Expand Down
33 changes: 33 additions & 0 deletions crates/oxc_linter/src/snapshots/react_rules_of_hooks.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: crates/oxc_linter/src/tester.rs
assertion_line: 362
---
⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useConditionalHook" is called conditionally. React Hooks must be called in the exact same order in every component render.
╭─[rules_of_hooks.tsx:4:18]
Expand All @@ -9,6 +10,38 @@ source: crates/oxc_linter/src/tester.rs
5 │ }
╰────

⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useHook1" is called conditionally. React Hooks must be called in the exact same order in every component render.
╭─[rules_of_hooks.tsx:3:22]
2 │ function useHook() {
3 │ a && useHook1();
· ──────────
4 │ b && useHook2();
╰────

⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useHook2" is called conditionally. React Hooks must be called in the exact same order in every component render.
╭─[rules_of_hooks.tsx:4:22]
3 │ a && useHook1();
4 │ b && useHook2();
· ──────────
5 │ }
╰────

⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useHook" is called conditionally. React Hooks must be called in the exact same order in every component render.
╭─[rules_of_hooks.tsx:5:21]
4 │ // This is invalid because the hook is called conditionally
5 │ useHook();
· ─────────
6 │ }
╰────

⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useHook" is called conditionally. React Hooks must be called in the exact same order in every component render.
╭─[rules_of_hooks.tsx:3:29]
2 │ function Component() {
3 │ condition ? useHook() : null;
· ─────────
4 │ }
╰────

⚠ eslint-plugin-react-hooks(rules-of-hooks): React Hook "useState" cannot be called at the top level. React Hooks must be called in a React function component or a custom React Hook function.
╭─[rules_of_hooks.tsx:2:13]
1 │
Expand Down