Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/metal-news-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@biomejs/biome": patch
---

Fixed [#11335](https://github.com/biomejs/biome/issues/11335): [`noComponentHookFactories`](https://biomejs.dev/linter/rules/no-component-hook-factories/) now reports a `use`-prefixed variable only when a function is assigned to it directly.

```js
function factory() {
const useColors = true; // no longer reported
const useStore = createStore({ count: 0 }); // no longer reported
const useData = () => useState(null); // still reported
return useColors;
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use biome_analyze::{
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_syntax::{
AnyJsArrowFunctionParameters, AnyJsFunction, AnyJsParameter, JsAssignmentExpression,
AnyJsArrowFunctionParameters, AnyJsExpression, AnyJsFunction, AnyJsParameter,
JsAssignmentExpression,
JsCallExpression, JsFunctionExpression, JsInitializerClause, JsPropertyClassMember,
JsPropertyObjectMember, JsSyntaxNode, JsSyntaxToken,
};
Expand Down Expand Up @@ -118,9 +119,7 @@ impl Rule for NoComponentHookFactories {
}

// Hooks are cheap to detect by name; check before the heavier component detection.
if let Some(token) =
get_simple_binding_token(decl).filter(|t| is_react_hook_name(t.text_trimmed()))
{
if let Some(token) = get_hook_binding_token(decl) {
let parent_fn = find_parent_function(syntax)?;
if is_hoc_like(&parent_fn) || is_inside_test_mock_callback(syntax) {
return None;
Expand Down Expand Up @@ -199,23 +198,37 @@ impl Rule for NoComponentHookFactories {
}
}

/// Returns the identifier token of the declared name for simple function/variable declarations.
/// Used to detect hooks by naming convention before heavier component detection.
fn get_simple_binding_token(node: &AnyPotentialReactComponentDeclaration) -> Option<JsSyntaxToken> {
/// Selects declarations that bind a function to a hook name, as determined by
/// [is_react_hook_name].
fn get_hook_binding_token(node: &AnyPotentialReactComponentDeclaration) -> Option<JsSyntaxToken> {
match node {
AnyPotentialReactComponentDeclaration::JsFunctionDeclaration(decl) => decl
.id()
.ok()?
.as_js_identifier_binding()?
.name_token()
.ok(),
AnyPotentialReactComponentDeclaration::JsVariableDeclarator(decl) => decl
.id()
.ok()?
.as_any_js_binding()?
.as_js_identifier_binding()?
.name_token()
.ok(),
.ok()
.filter(|name| is_react_hook_name(name.text_trimmed())),
AnyPotentialReactComponentDeclaration::JsVariableDeclarator(decl) => {
let name = decl
.id()
.ok()?
.as_any_js_binding()?
.as_js_identifier_binding()?
.name_token()
.ok()?;
if !is_react_hook_name(name.text_trimmed()) {
return None;
}

let initializer = decl.initializer()?.expression().ok()?.inner_expression()?;
matches!(
initializer,
AnyJsExpression::JsArrowFunctionExpression(_)
| AnyJsExpression::JsFunctionExpression(_)
)
.then_some(name)
}
_ => None,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,19 @@ function setup() {
}
return Tooltip;
}

// Hook defined with a function expression initializer
function createStoredHook(key) {
const useStored = function () {
return useState(key);
};
return useStored;
}

// Hook defined with a parenthesized arrow initializer
function createWrappedHook(key) {
const useWrapped = (() => {
return useState(key);
});
return useWrapped;
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ function setup() {
return Tooltip;
}

// Hook defined with a function expression initializer
function createStoredHook(key) {
const useStored = function () {
return useState(key);
};
return useStored;
}

// Hook defined with a parenthesized arrow initializer
function createWrappedHook(key) {
const useWrapped = (() => {
return useState(key);
});
return useWrapped;
}

```

# Diagnostics
Expand Down Expand Up @@ -604,3 +620,41 @@ invalid.jsx:175:12 lint/nursery/noComponentHookFactories ━━━━━━━


```

```
invalid.jsx:183:9 lint/nursery/noComponentHookFactories ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Do not define hook useStored inside a function.

181 │ // Hook defined with a function expression initializer
182 │ function createStoredHook(key) {
> 183 │ const useStored = function () {
│ ^^^^^^^^^
184 │ return useState(key);
185 │ };

i Each call creates a new hook identity, causing React to lose its state across renders. Move it to the module level.

i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.


```

```
invalid.jsx:191:9 lint/nursery/noComponentHookFactories ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Do not define hook useWrapped inside a function.

189 │ // Hook defined with a parenthesized arrow initializer
190 │ function createWrappedHook(key) {
> 191 │ const useWrapped = (() => {
│ ^^^^^^^^^^
192 │ return useState(key);
193 │ });

i Each call creates a new hook identity, causing React to lose its state across renders. Move it to the module level.

i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* should generate diagnostics */

type Hook = () => [number, (value: number) => void];

// Hook defined with a type assertion around the function
function createAssertedHook(key: number) {
const useAsserted = (() => {
return useState(key);
}) as Hook;
return useAsserted;
}

// Hook defined with a satisfies operator around the function
function createSatisfyingHook(key: number) {
const useSatisfying = (() => {
return useState(key);
}) satisfies Hook;
return useSatisfying;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalidTypeAssertion.tsx
---
# Input
```tsx
/* should generate diagnostics */

type Hook = () => [number, (value: number) => void];

// Hook defined with a type assertion around the function
function createAssertedHook(key: number) {
const useAsserted = (() => {
return useState(key);
}) as Hook;
return useAsserted;
}

// Hook defined with a satisfies operator around the function
function createSatisfyingHook(key: number) {
const useSatisfying = (() => {
return useState(key);
}) satisfies Hook;
return useSatisfying;
}

```

# Diagnostics
```
invalidTypeAssertion.tsx:7:9 lint/nursery/noComponentHookFactories ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Do not define hook useAsserted inside a function.

5 │ // Hook defined with a type assertion around the function
6 │ function createAssertedHook(key: number) {
> 7 │ const useAsserted = (() => {
│ ^^^^^^^^^^^
8 │ return useState(key);
9 │ }) as Hook;

i Each call creates a new hook identity, causing React to lose its state across renders. Move it to the module level.

i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.


```

```
invalidTypeAssertion.tsx:15:9 lint/nursery/noComponentHookFactories ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Do not define hook useSatisfying inside a function.

13 │ // Hook defined with a satisfies operator around the function
14 │ function createSatisfyingHook(key: number) {
> 15 │ const useSatisfying = (() => {
│ ^^^^^^^^^^^^^
16 │ return useState(key);
17 │ }) satisfies Hook;

i Each call creates a new hook identity, causing React to lose its state across renders. Move it to the module level.

i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.


```
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,13 @@ jest.mock('../path/to/go', () => ({
export default function App() {
return <div />;
}

// `use`-prefixed bindings that are not functions are not hooks
function factory() {
const useColors = true;
const useLabel = "label";
const useCount = 42;
const useConfig = { enabled: true };
const useStore = createStore({ count: 0 });
return useColors;
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,14 @@ export default function App() {
return <div />;
}

// `use`-prefixed bindings that are not functions are not hooks
function factory() {
const useColors = true;
const useLabel = "label";
const useCount = 42;
const useConfig = { enabled: true };
const useStore = createStore({ count: 0 });
return useColors;
}

```
Loading