From 1f5afd20ee1e4d6be4ba9476f8509c9ed0821a97 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 29 Mar 2026 05:52:18 +0530 Subject: [PATCH 1/3] fix(lint): handle forwardRef callbacks in useHookAtTopLevel --- .../lint/correctness/use_hook_at_top_level.rs | 89 ++++++++++++++++--- .../correctness/useHookAtTopLevel/valid.js | 14 +++ .../useHookAtTopLevel/valid.js.snap | 14 +++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs b/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs index 780e15271cfb..15754c535200 100644 --- a/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs +++ b/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs @@ -1,4 +1,5 @@ use crate::react::hooks::{is_react_hook_call, is_react_hook_name}; +use crate::react::{ReactLibrary, is_react_call_api}; use crate::services::semantic::{SemanticModelBuilderVisitor, SemanticServices}; use biome_analyze::{ AddVisitor, FromServices, Phase, Phases, QueryMatch, Queryable, Rule, RuleDiagnostic, RuleKey, @@ -9,12 +10,13 @@ use biome_analyze::{RuleDomain, RuleSource}; use biome_console::markup; use biome_js_semantic::{CallsExtensions, SemanticModel}; use biome_js_syntax::{ - AnyFunctionLike, AnyJsBinding, AnyJsClassMemberName, AnyJsExpression, AnyJsFunction, - AnyJsObjectMemberName, JsArrayAssignmentPatternElement, JsArrayBindingPatternElement, - JsCallExpression, JsConditionalExpression, JsGetterClassMember, JsGetterObjectMember, - JsIfStatement, JsLanguage, JsLogicalExpression, JsMethodClassMember, JsMethodObjectMember, - JsObjectBindingPatternShorthandProperty, JsReturnStatement, JsSetterClassMember, - JsSetterObjectMember, JsSyntaxKind, JsSyntaxNode, JsTryFinallyStatement, TextRange, + AnyFunctionLike, AnyJsBinding, AnyJsCallArgument, AnyJsClassMemberName, AnyJsExpression, + AnyJsFunction, AnyJsObjectMemberName, JsArrayAssignmentPatternElement, + JsArrayBindingPatternElement, JsCallExpression, JsConditionalExpression, JsGetterClassMember, + JsGetterObjectMember, JsIfStatement, JsLanguage, JsLogicalExpression, JsMethodClassMember, + JsMethodObjectMember, JsObjectBindingPatternShorthandProperty, JsReturnStatement, + JsSetterClassMember, JsSetterObjectMember, JsSyntaxKind, JsSyntaxNode, JsTryFinallyStatement, + TextRange, }; use biome_rowan::{AstNode, Language, SyntaxNode, Text, WalkEvent, declare_node_union}; use rustc_hash::FxHashMap; @@ -89,10 +91,13 @@ declare_node_union! { } impl AnyJsFunctionOrMethod { - fn is_react_component_or_hook(&self) -> bool { + fn is_react_component_or_hook(&self, model: &SemanticModel) -> bool { if ReactComponentInfo::from_function(self.syntax()).is_some() { return true; } + if self.is_forward_ref_render_function(model) { + return true; + } if let Some(name) = self.name() { return is_react_hook_name(&name); } @@ -128,6 +133,61 @@ impl AnyJsFunctionOrMethod { .map(AnyJsObjectMemberName::to_trimmed_text), } } + + fn is_forward_ref_render_function(&self, model: &SemanticModel) -> bool { + let Self::AnyJsFunction(function) = self else { + return false; + }; + let Ok(binding) = function.binding() else { + return false; + }; + let Some(binding) = binding.as_js_identifier_binding() else { + return false; + }; + + model.as_binding(binding).all_references().any(|reference| { + let Some(expression) = reference + .syntax() + .ancestors() + .find_map(AnyJsExpression::cast) + .map(AnyJsExpression::omit_parentheses) + else { + return false; + }; + + if !matches!(expression, AnyJsExpression::JsIdentifierExpression(_)) { + return false; + } + + let Some(argument) = expression.syntax().parent::() else { + return false; + }; + let Some(argument_expression) = argument.as_any_js_expression() else { + return false; + }; + if argument_expression.omit_parentheses().syntax() != expression.syntax() { + return false; + } + + let Some(call_expression) = argument + .syntax() + .ancestors() + .find_map(JsCallExpression::cast) + else { + return false; + }; + let Ok(callee) = call_expression.callee() else { + return false; + }; + + is_react_call_api( + &callee.omit_parentheses(), + model, + ReactLibrary::React, + "forwardRef", + ) + }) + } } pub struct Suggestion { @@ -254,8 +314,11 @@ fn is_conditional_expression(parent_node: &JsSyntaxNode, node: &JsSyntaxNode) -> ) } -fn is_nested_function_inside_component_or_hook(function: &AnyJsFunctionOrMethod) -> bool { - if function.is_react_component_or_hook() { +fn is_nested_function_inside_component_or_hook( + function: &AnyJsFunctionOrMethod, + model: &SemanticModel, +) -> bool { + if function.is_react_component_or_hook(model) { return false; } @@ -265,7 +328,7 @@ fn is_nested_function_inside_component_or_hook(function: &AnyJsFunctionOrMethod) parent.ancestors().any(|node| { AnyJsFunctionOrMethod::cast(node) - .is_some_and(|enclosing_function| enclosing_function.is_react_component_or_hook()) + .is_some_and(|enclosing_function| enclosing_function.is_react_component_or_hook(model)) }) } @@ -541,7 +604,7 @@ impl Rule for UseHookAtTopLevel { path.push(range); if let Some(enclosing_function) = enclosing_function_if_call_is_at_top_level(&call) { - if is_nested_function_inside_component_or_hook(&enclosing_function) { + if is_nested_function_inside_component_or_hook(&enclosing_function, model) { // We cannot allow nested functions inside hooks and // components, since it would break the requirement for // hooks to be called from the top-level. @@ -561,7 +624,7 @@ impl Rule for UseHookAtTopLevel { } let enclosed = is_enclosed_in_component_or_hook - || enclosing_function.is_react_component_or_hook(); + || enclosing_function.is_react_component_or_hook(model); if let AnyJsFunctionOrMethod::AnyJsFunction(function) = enclosing_function && let Some(calls_iter) = function.all_calls(model) @@ -590,7 +653,7 @@ impl Rule for UseHookAtTopLevel { } if enclosing_function_if_call_is_at_top_level(call).is_some_and(|function| { - !function.is_react_component_or_hook() && !function.is_function_expression() + !function.is_react_component_or_hook(model) && !function.is_function_expression() }) { return Some(Suggestion { hook_name_range: get_hook_name_range()?, diff --git a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js index b234f74d71d9..6fe0ce6cfd31 100644 --- a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js +++ b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js @@ -94,6 +94,20 @@ function Component7() { useEffect(); }; +function ForwardRefComponent(props, ref) { + const forwardedRef = useRef(); + return
; +} + +const WrappedForwardRefComponent = forwardRef(ForwardRefComponent); + +function ReactForwardRefComponent(props, ref) { + const forwardedRef = useRef(); + return
; +} + +const WrappedReactForwardRefComponent = React.forwardRef(ReactForwardRefComponent); + test('a', () => { function TestComponent() { useState(); diff --git a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js.snap b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js.snap index e22f72800c4a..c94fca38ade5 100644 --- a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js.snap +++ b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/valid.js.snap @@ -100,6 +100,20 @@ function Component7() { useEffect(); }; +function ForwardRefComponent(props, ref) { + const forwardedRef = useRef(); + return
; +} + +const WrappedForwardRefComponent = forwardRef(ForwardRefComponent); + +function ReactForwardRefComponent(props, ref) { + const forwardedRef = useRef(); + return
; +} + +const WrappedReactForwardRefComponent = React.forwardRef(ReactForwardRefComponent); + test('a', () => { function TestComponent() { useState(); From 4df61ad87ad1235c48e1a5919d5ba02d45ba3327 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 29 Mar 2026 07:21:29 +0530 Subject: [PATCH 2/3] fix(lint): annotate forwardRef callee type --- .../src/lint/correctness/use_hook_at_top_level.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs b/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs index 15754c535200..ca00ccdbeabb 100644 --- a/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs +++ b/crates/biome_js_analyze/src/lint/correctness/use_hook_at_top_level.rs @@ -176,7 +176,7 @@ impl AnyJsFunctionOrMethod { else { return false; }; - let Ok(callee) = call_expression.callee() else { + let Ok(callee): Result = call_expression.callee() else { return false; }; From bd86ae13080ed2978eb15e5cfeecdb71a02a71d2 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:04:38 +0530 Subject: [PATCH 3/3] fix: add changeset for forwardRef hook fix --- .changeset/fix-use-hook-at-top-level-forward-ref.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-use-hook-at-top-level-forward-ref.md diff --git a/.changeset/fix-use-hook-at-top-level-forward-ref.md b/.changeset/fix-use-hook-at-top-level-forward-ref.md new file mode 100644 index 000000000000..9c1c9a9dc532 --- /dev/null +++ b/.changeset/fix-use-hook-at-top-level-forward-ref.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9195](https://github.com/biomejs/biome/issues/9195): `useHookAtTopLevel` no longer reports false positives for component render functions passed to `forwardRef` or `React.forwardRef`.