Skip to content

Commit b3de93c

Browse files
committed
fix(linter/rules-of-hooks): clarify conditional diagnostics (#22030)
Clarifies conditional Rules of Hooks diagnostics by labeling the Hook call and the conditional path that skips it.
1 parent 4f9f629 commit b3de93c

2 files changed

Lines changed: 346 additions & 55 deletions

File tree

crates/oxc_linter/src/rules/react/rules_of_hooks.rs

Lines changed: 151 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use oxc_cfg::{
1111
use oxc_macros::declare_oxc_lint;
1212
use oxc_semantic::{AstNodes, NodeId};
1313
use oxc_span::{GetSpan, Span};
14-
use oxc_syntax::operator::AssignmentOperator;
14+
use oxc_syntax::operator::{AssignmentOperator, LogicalOperator};
1515

1616
use crate::{
1717
AstNode,
@@ -25,6 +25,11 @@ mod diagnostics {
2525
use oxc_span::Span;
2626
const SCOPE: &str = "react-hooks";
2727

28+
pub(super) struct ConditionalContext {
29+
pub span: Span,
30+
pub label: &'static str,
31+
}
32+
2833
pub(super) fn function_error(
2934
react_hook_span: Span,
3035
outer_function_span: Span,
@@ -44,13 +49,29 @@ mod diagnostics {
4449
.with_error_code_scope(SCOPE)
4550
}
4651

47-
pub(super) fn conditional_hook(span: Span, hook_name: &str) -> OxcDiagnostic {
48-
OxcDiagnostic::warn(format!(
52+
pub(super) fn conditional_hook(
53+
span: Span,
54+
hook_name: &str,
55+
conditional_context: Option<ConditionalContext>,
56+
) -> OxcDiagnostic {
57+
let diagnostic = OxcDiagnostic::warn(format!(
4958
"React Hook {hook_name:?} is called conditionally. React Hooks must be \
5059
called in the exact same order in every component render."
5160
))
52-
.with_label(span)
53-
.with_error_code_scope(SCOPE)
61+
.with_help(
62+
"Move the Hook call before the condition, or call it unconditionally and branch inside the Hook/effect instead.",
63+
)
64+
.with_error_code_scope(SCOPE);
65+
66+
if let Some(context) = conditional_context {
67+
diagnostic.with_labels([
68+
span.primary_label("This Hook call is not reachable on every render path."),
69+
context.span.label(context.label),
70+
])
71+
} else {
72+
diagnostic
73+
.with_label(span.label("This Hook call is not reachable on every render path."))
74+
}
5475
}
5576

5677
pub(super) fn loop_hook(
@@ -78,7 +99,7 @@ mod diagnostics {
7899
must be called in a React function component or a custom React \
79100
Hook function."
80101
))
81-
.with_label(span)
102+
.with_label(span.label("This Hook call is outside a component or custom Hook."))
82103
.with_error_code_scope(SCOPE)
83104
}
84105

@@ -129,7 +150,7 @@ mod diagnostics {
129150
must be called in a React function component or a custom React \
130151
Hook function."
131152
))
132-
.with_label(span)
153+
.with_label(span.label("This Hook call is inside a nested callback."))
133154
.with_error_code_scope(SCOPE)
134155
}
135156
}
@@ -354,7 +375,11 @@ impl Rule for RulesOfHooks {
354375

355376
if has_conditional_path_accept_throw(ctx.nodes(), cfg, parent_func, node) {
356377
#[expect(clippy::needless_return)]
357-
return ctx.diagnostic(diagnostics::conditional_hook(span, hook_name));
378+
return ctx.diagnostic(diagnostics::conditional_hook(
379+
span,
380+
hook_name,
381+
conditional_context(ctx, node.id(), span, parent_func.id()),
382+
));
358383
}
359384
}
360385
}
@@ -416,6 +441,103 @@ fn loop_keyword_span(
416441
None
417442
}
418443

444+
/// Find the nearest conditional construct that can skip this Hook call.
445+
///
446+
/// Hooks inside condition/test expressions are evaluated before that branch is
447+
/// chosen, so keep walking until we find an ancestor that makes the Hook itself
448+
/// unreachable on some render path.
449+
#[expect(clippy::cast_possible_truncation)]
450+
fn conditional_context(
451+
ctx: &LintContext<'_>,
452+
hook_node_id: NodeId,
453+
hook_span: Span,
454+
function_node_id: NodeId,
455+
) -> Option<diagnostics::ConditionalContext> {
456+
let nodes = ctx.nodes();
457+
for ancestor in nodes.ancestors(hook_node_id) {
458+
if ancestor.id() == function_node_id {
459+
break;
460+
}
461+
462+
let context = match ancestor.kind() {
463+
AstKind::IfStatement(stmt) => {
464+
let test_span = stmt.test.span();
465+
if test_span.contains_inclusive(hook_span) {
466+
continue;
467+
}
468+
469+
let label = if stmt
470+
.alternate
471+
.as_ref()
472+
.is_some_and(|alternate| alternate.span().contains_inclusive(hook_span))
473+
{
474+
"When this condition is true, this Hook is skipped."
475+
} else {
476+
"When this condition is false, this Hook is skipped."
477+
};
478+
479+
diagnostics::ConditionalContext { span: test_span, label }
480+
}
481+
AstKind::ConditionalExpression(expr) => {
482+
let test_span = expr.test.span();
483+
if test_span.contains_inclusive(hook_span) {
484+
continue;
485+
}
486+
487+
diagnostics::ConditionalContext {
488+
span: test_span,
489+
label: "Only one side of this conditional expression calls the Hook.",
490+
}
491+
}
492+
AstKind::LogicalExpression(expr) => {
493+
if expr.left.span().contains_inclusive(hook_span) {
494+
continue;
495+
}
496+
497+
diagnostics::ConditionalContext {
498+
span: expr.left.span(),
499+
label: match expr.operator {
500+
LogicalOperator::And => {
501+
"This short-circuits when falsy, skipping the Hook call."
502+
}
503+
LogicalOperator::Or => {
504+
"This short-circuits when truthy, skipping the Hook call."
505+
}
506+
LogicalOperator::Coalesce => {
507+
"This short-circuits when not nullish, skipping the Hook call."
508+
}
509+
},
510+
}
511+
}
512+
AstKind::SwitchCase(case) => {
513+
let case_span = if let Some(test) = &case.test {
514+
test.span()
515+
} else {
516+
let header_end =
517+
case.consequent.first().map_or(case.span.end, |stmt| stmt.span().start);
518+
let default_start = case.span.start
519+
+ ctx.find_next_token_within(case.span.start, header_end, "default")?;
520+
Span::sized(default_start, "default".len() as u32)
521+
};
522+
523+
if case_span.contains_inclusive(hook_span) {
524+
continue;
525+
}
526+
527+
diagnostics::ConditionalContext {
528+
span: case_span,
529+
label: "Only this switch case calls the Hook.",
530+
}
531+
}
532+
_ => continue,
533+
};
534+
535+
return Some(context);
536+
}
537+
538+
None
539+
}
540+
419541
fn has_conditional_path_accept_throw(
420542
nodes: &AstNodes<'_>,
421543
cfg: &ControlFlowGraph,
@@ -1228,6 +1350,27 @@ fn test() {
12281350
return <Content />;
12291351
}
12301352
",
1353+
"
1354+
function Component() {
1355+
switch (foo) {
1356+
case 1:
1357+
useCaseHook();
1358+
break;
1359+
default:
1360+
break;
1361+
}
1362+
}
1363+
",
1364+
"
1365+
function Component() {
1366+
switch (foo) {
1367+
case 1:
1368+
break;
1369+
default:
1370+
useDefaultHook();
1371+
}
1372+
}
1373+
",
12311374
// Invalid because hooks can only be called inside of a component.
12321375
// errors: [
12331376
// topLevelError('Hook.useState'),

0 commit comments

Comments
 (0)