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
5 changes: 5 additions & 0 deletions .changeset/rules-of-hooks-hoc-callback-binding-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"oxlint-plugin-react-doctor": patch
---

`rules-of-hooks` and `exhaustive-deps` no longer report false positives for hooks called inside a `forwardRef(...)` / `memo(...)` render callback whose binding name is not PascalCase (e.g. `const _Wrapped = forwardRef((props, ref) => { useHook(); ... })`). The render callback passed as the first argument to React's HoCs is a component by construction, so both rules now treat it as one regardless of the variable name it lands on. Only the first argument is promoted — hooks inside `memo`'s second argument (the props comparator) still report, as do genuinely non-component functions like `const _helper = () => { useState(); }`.
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,30 @@ describe("react-builtins/exhaustive-deps — regressions", () => {
const messages = result.diagnostics.map((diagnostic) => diagnostic.message).join("\n");
expect(messages).toContain("value");
});

// The render callback passed directly to forwardRef/memo is a
// component by construction, even under a non-PascalCase binding
// (`const _Wrapped = forwardRef(...)`). Without that promotion the
// component-scope boundary resolves to null and captures from an
// enclosing factory scope are wrongly reported as missing deps —
// they live outside the component, so they can't change between
// renders.
it("does not flag factory-scope captures inside a forwardRef callback under an underscore-prefixed binding", () => {
const code = `
import { forwardRef, useEffect } from "react";
const buildComponent = (logger) => {
const _Wrapped = forwardRef((props, ref) => {
useEffect(() => {
logger(props.value);
}, [props.value]);
return <div ref={ref} />;
});
return _Wrapped;
};
`;
const result = runRule(exhaustiveDeps, code, { filename: "fixture.tsx" });
expect(result.parseErrors).toEqual([]);
const messages = result.diagnostics.map((diagnostic) => diagnostic.message).join("\n");
expect(messages).not.toContain("logger");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
import { getStaticTemplateLiteralValue } from "../../utils/get-static-template-literal-value.js";
import { isAstNode } from "../../utils/is-ast-node.js";
import { isReactComponentOrHookName } from "../../utils/is-react-component-or-hook-name.js";
import { isReactHocCallbackArgument } from "../../utils/is-react-hoc-callback-argument.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { Rule } from "../../utils/rule.js";
import { REACT_HOC_NAMES } from "../../constants/react.js";
Expand Down Expand Up @@ -133,6 +134,7 @@ const findEnclosingComponentOrHookFunction = (node: EsTreeNode): EsTreeNode | nu
isNodeOfType(current, "FunctionExpression") ||
isNodeOfType(current, "ArrowFunctionExpression")
) {
if (isReactHocCallbackArgument(current)) return current;
const functionName = inferFunctionName(current);
if (functionName && isReactComponentOrHookName(functionName)) return current;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { rulesOfHooks } from "./rules-of-hooks.js";

const runTsx = (code: string) => runRule(rulesOfHooks, code, { filename: "fixture.tsx" });

describe("react-builtins/rules-of-hooks — regressions: HoC callbacks under non-PascalCase bindings", () => {
it("does not flag hooks in a forwardRef callback bound to an underscore-prefixed name", () => {
const result = runTsx(`
import { forwardRef, useState } from "react";
const _Wrapped = forwardRef((props, ref) => {
const [value] = useState(0);
return <div ref={ref}>{value}</div>;
});
export const Wrapped = _Wrapped;
`);
expect(result.diagnostics).toEqual([]);
});

it("does not flag hooks in a memo callback bound to an underscore-prefixed name", () => {
const result = runTsx(`
import { memo, useState } from "react";
const _Memoized = memo(function (props) {
const [value] = useState(0);
return <span>{value}</span>;
});
export const Memoized = _Memoized;
`);
expect(result.diagnostics).toEqual([]);
});

it("does not flag hooks in a React.forwardRef callback assigned to a lowercase binding", () => {
const result = runTsx(`
import * as React from "react";
const _wrapped = React.forwardRef((props, ref) => {
React.useEffect(() => {}, []);
return <div ref={ref} />;
});
export default _wrapped;
`);
expect(result.diagnostics).toEqual([]);
});

it("does not flag hooks in a forwardRef callback inside memo(forwardRef(...))", () => {
const result = runTsx(`
import { memo, forwardRef, useState } from "react";
const _Wrapped = memo(
forwardRef((props, ref) => {
const [value] = useState(0);
return <div ref={ref}>{value}</div>;
}),
);
export const Wrapped = _Wrapped;
`);
expect(result.diagnostics).toEqual([]);
});

it("still flags hooks in a non-component underscore-prefixed function", () => {
const result = runTsx(`
import { useState } from "react";
const _helper = () => {
const [value] = useState(0);
return value;
};
`);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.message).toBe(
"`useState` runs inside `_helper`, which is not a component or Hook, so React cannot attach Hook state to a render.",
);
});

it("still flags hooks in a named callback passed to an arbitrary non-React HoC", () => {
const result = runTsx(`
import { useState } from "react";
const _wrapped = trackEvents(function _process() {
const [value] = useState(0);
return value;
});
`);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.message).toBe(
"`useState` runs inside `_process`, which is not a component or Hook, so React cannot attach Hook state to a render.",
);
});

it("still flags hooks in a memo props comparator (second argument is not a render callback)", () => {
const result = runTsx(`
import { memo, useState } from "react";
const _Memoized = memo(
(props) => <span>{props.value}</span>,
(previousProps, nextProps) => {
const [shouldSkip] = useState(false);
return shouldSkip;
},
);
export const Memoized = _Memoized;
`);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.message).toBe(
"`useState` runs inside `_Memoized`, which is not a component or Hook, so React cannot attach Hook state to a render.",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isReactComponentOrHookName } from "../../utils/is-react-component-or-ho
import { isReactHookName } from "../../utils/is-react-hook-name.js";
import { REACT_HOC_NAMES } from "../../constants/react.js";
import { isFunctionLike } from "../../utils/is-function-like.js";
import { isReactHocCallbackArgument } from "../../utils/is-react-hoc-callback-argument.js";
import type { Rule } from "../../utils/rule.js";

// Port of `oxc_linter::rules::react::rules_of_hooks`. Enforces React's
Expand Down Expand Up @@ -437,7 +438,9 @@ const findEnclosingFunctionInfo = (node: EsTreeNode): FunctionInfo | null => {
name: displayName,
hasResolvedName: resolvedName !== null,
isAsync: Boolean(current.async),
isComponentOrHook: resolvedName === null ? false : isReactComponentOrHookName(displayName),
isComponentOrHook:
isReactHocCallbackArgument(current) ||
(resolvedName === null ? false : isReactComponentOrHookName(displayName)),
};
}
current = current.parent ?? null;
Expand Down Expand Up @@ -553,6 +556,7 @@ const findEnclosingComponentOrHookFunction = (node: EsTreeNode): EsTreeNode | nu
let current: EsTreeNode | null | undefined = node.parent;
while (current) {
if (isFunctionLike(current)) {
if (isReactHocCallbackArgument(current)) return current;
const resolvedName = inferFunctionName(current);
if (resolvedName !== null && isReactComponentOrHookName(resolvedName)) return current;
}
Expand Down Expand Up @@ -690,32 +694,32 @@ export const rulesOfHooks = defineRule<Rule>({
return;
}

// For anonymous callbacks, look outward: if any enclosing
// function IS a component / custom hook, this nested anonymous
// callback can't legally call a hook (Rule of Hooks forbids
// hooks in nested callbacks even when the outer function is a
// component). When NO outer function is a component / hook, the
// callback's runtime context is unknown — skip to avoid false
// positives on generic callbacks (utility / event-handler
// factories).
if (!enclosing.hasResolvedName) {
let outerWalker: EsTreeNode | null = enclosing.node;
let outerIsComponentOrHook = false;
while (outerWalker) {
const outerInfo = findEnclosingFunctionInfo(outerWalker);
if (!outerInfo) break;
if (outerInfo.isComponentOrHook) {
outerIsComponentOrHook = true;
break;
if (!enclosing.isComponentOrHook) {
// For anonymous callbacks, look outward: if any enclosing
// function IS a component / custom hook, this nested anonymous
// callback can't legally call a hook (Rule of Hooks forbids
// hooks in nested callbacks even when the outer function is a
// component). When NO outer function is a component / hook, the
// callback's runtime context is unknown — skip to avoid false
// positives on generic callbacks (utility / event-handler
// factories).
if (!enclosing.hasResolvedName) {
let outerWalker: EsTreeNode | null = enclosing.node;
let outerIsComponentOrHook = false;
while (outerWalker) {
const outerInfo = findEnclosingFunctionInfo(outerWalker);
if (!outerInfo) break;
if (outerInfo.isComponentOrHook) {
outerIsComponentOrHook = true;
break;
}
outerWalker = outerInfo.node;
}
outerWalker = outerInfo.node;
if (!outerIsComponentOrHook) return;
context.report({ node: node.callee, message: buildConditionalMessage(hookName) });
return;
}
if (!outerIsComponentOrHook) return;
context.report({ node: node.callee, message: buildConditionalMessage(hookName) });
return;
}

if (!enclosing.isComponentOrHook) {
context.report({
node: node.callee,
message: buildNonComponentMessage(hookName, enclosing.name),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { REACT_HOC_NAMES } from "../constants/react.js";
import type { EsTreeNode } from "./es-tree-node.js";
import { isNodeOfType } from "./is-node-of-type.js";

const reactHocCalleeName = (callee: EsTreeNode): string | null => {
if (isNodeOfType(callee, "Identifier")) return callee.name;
if (
isNodeOfType(callee, "MemberExpression") &&
!callee.computed &&
isNodeOfType(callee.object, "Identifier") &&
callee.object.name === "React" &&
isNodeOfType(callee.property, "Identifier")
) {
return `React.${callee.property.name}`;
}
return null;
};

// Mirrors upstream eslint-plugin-react-hooks: the render callback
// passed as the FIRST argument to `memo(...)` / `forwardRef(...)` IS
// a component by construction, regardless of what binding name it
// ends up under (`const _Wrapped = forwardRef((props, ref) => …)`).
// Later arguments are not render callbacks (`memo`'s second argument
// is the props comparator), so they are never promoted.
export const isReactHocCallbackArgument = (functionNode: EsTreeNode): boolean => {
const parent = functionNode.parent;
if (!parent || !isNodeOfType(parent, "CallExpression")) return false;
if (parent.arguments[0] !== functionNode) return false;
const calleeName = reactHocCalleeName(parent.callee);
return calleeName !== null && REACT_HOC_NAMES.has(calleeName);
};
Loading