@@ -11,7 +11,7 @@ use oxc_cfg::{
1111use oxc_macros:: declare_oxc_lint;
1212use oxc_semantic:: { AstNodes , NodeId } ;
1313use oxc_span:: { GetSpan , Span } ;
14- use oxc_syntax:: operator:: AssignmentOperator ;
14+ use oxc_syntax:: operator:: { AssignmentOperator , LogicalOperator } ;
1515
1616use 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+
419541fn 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