1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const HOOK_NAME = /^use[A-Z]/;
4// Bare `use` is React 19's `use(promise | context)`, which is a hook call too.
5const HOOK_CALLEE = /^use([A-Z]|$)/;
6
7type FunctionNode = ESTree.Function | ESTree.ArrowFunctionExpression;
8
9function isFunctionNode(node: ESTree.Node): node is FunctionNode {
10 return (
11 node.type === "FunctionDeclaration" ||
12 node.type === "FunctionExpression" ||
13 node.type === "ArrowFunctionExpression"
14 );
15}
16
17function isHookCallee(callee: ESTree.Node): boolean {
18 if (callee.type === "Identifier") return HOOK_CALLEE.test(callee.name);
19 if (callee.type === "MemberExpression" && !callee.computed) {
20 return callee.property.type === "Identifier" && HOOK_CALLEE.test(callee.property.name);
21 }
22 return false;
23}
24
25// A hook call inside a nested callback runs when the callback runs, not during render,
26// so it does not make the outer function a hook.
27function callsHook(node: unknown, root: unknown): boolean {
28 if (typeof node !== "object" || node === null) return false;
29 if (Array.isArray(node)) return node.some((child) => callsHook(child, root));
30 if (!("type" in node) || typeof node.type !== "string") return false;
31 const typed = node as ESTree.Node;
32 if (typed !== root && isFunctionNode(typed)) return false;
33 if (typed.type === "CallExpression" && isHookCallee(typed.callee)) return true;
34 for (const [key, value] of Object.entries(typed)) {
35 if (key === "parent") continue;
36 if (callsHook(value, root)) return true;
37 }
38 return false;
39}
40
41export default defineRule({
42 meta: {
43 type: "problem",
44 docs: {
45 description:
46 "Disallow use* functions that call no hooks; the React Compiler only compiles functions that do. Mirrors eslint-react's no-unnecessary-use-prefix, which oxlint does not ship.",
47 },
48 schema: [],
49 },
50 create(context) {
51 const check = (name: string, fn: FunctionNode, reportNode: ESTree.Node) => {
52 if (!HOOK_NAME.test(name)) return;
53 if (callsHook(fn.body, fn)) return;
54 context.report({
55 node: reportNode,
56 message: `"${name}" calls no hooks, so the React Compiler skips it and whatever it returns is rebuilt on every render. Rename it to a plain function, or read the hook it depends on inside it.`,
57 });
58 };
59 return {
60 FunctionDeclaration(node) {
61 if (node.id) check(node.id.name, node, node.id);
62 },
63 VariableDeclarator(node) {
64 if (node.id.type !== "Identifier" || node.init == null) return;
65 if (
66 node.init.type !== "ArrowFunctionExpression" &&
67 node.init.type !== "FunctionExpression"
68 ) {
69 return;
70 }
71 check(node.id.name, node.init, node.id);
72 },
73 };
74 },
75});