diff --git a/packages/core/src/hook/hook-name.ts b/packages/core/src/hook/hook-name.ts index 361d346f43..9fc7d80b47 100644 --- a/packages/core/src/hook/hook-name.ts +++ b/packages/core/src/hook/hook-name.ts @@ -3,3 +3,7 @@ export const RE_HOOK_NAME = /^use[A-Z\d]/u; export function isReactHookName(name: string) { return name === "use" || RE_HOOK_NAME.test(name); } + +export function isReactHookNameLoose(name: string) { + return name.startsWith("use"); +} diff --git a/packages/core/src/hook/is.ts b/packages/core/src/hook/is.ts index 55d6680476..ead6d6b571 100644 --- a/packages/core/src/hook/is.ts +++ b/packages/core/src/hook/is.ts @@ -34,8 +34,8 @@ export function isReactHookCall(node: TSESTree.Node | _) { return false; } -export function isReactHookCallWithName(context: RuleContext, node: TSESTree.CallExpression | _) { - if (node == null) return constFalse; +export function isReactHookCallWithName(context: RuleContext, node: TSESTree.Node | _) { + if (node == null || node.type !== T.CallExpression) return constFalse; const { importSource = DEFAULT_ESLINT_REACT_SETTINGS.importSource, skipImportCheck = true, @@ -57,8 +57,8 @@ export function isReactHookCallWithName(context: RuleContext, node: TSESTree.Cal }; } -export function isReactHookCallWithNameLoose(node: TSESTree.CallExpression | _) { - if (node == null) return constFalse; +export function isReactHookCallWithNameLoose(node: TSESTree.Node | _) { + if (node == null || node.type !== T.CallExpression) return constFalse; return (name: string) => { switch (node.callee.type) { case T.Identifier: @@ -109,16 +109,20 @@ export function isUseEffectCallLoose(node: TSESTree.Node | _) { } } +export const isUseCall = flip(isReactHookCallWithName)("use"); +export const isUseActionStateCall = flip(isReactHookCallWithName)("useActionState"); export const isUseCallbackCall = flip(isReactHookCallWithName)("useCallback"); export const isUseContextCall = flip(isReactHookCallWithName)("useContext"); export const isUseDebugValueCall = flip(isReactHookCallWithName)("useDebugValue"); export const isUseDeferredValueCall = flip(isReactHookCallWithName)("useDeferredValue"); export const isUseEffectCall = flip(isReactHookCallWithName)("useEffect"); +export const isUseFormStatusCall = flip(isReactHookCallWithName)("useFormStatus"); export const isUseIdCall = flip(isReactHookCallWithName)("useId"); export const isUseImperativeHandleCall = flip(isReactHookCallWithName)("useImperativeHandle"); export const isUseInsertionEffectCall = flip(isReactHookCallWithName)("useInsertionEffect"); export const isUseLayoutEffectCall = flip(isReactHookCallWithName)("useLayoutEffect"); export const isUseMemoCall = flip(isReactHookCallWithName)("useMemo"); +export const isUseOptimisticCall = flip(isReactHookCallWithName)("useOptimistic"); export const isUseReducerCall = flip(isReactHookCallWithName)("useReducer"); export const isUseRefCall = flip(isReactHookCallWithName)("useRef"); export const isUseStateCall = flip(isReactHookCallWithName)("useState"); diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts index 5a9b889f29..2061388b92 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts @@ -5,178 +5,145 @@ import { allValid, ruleTester } from "../../../../../test"; import rule, { RULE_NAME } from "./prefer-use-state-lazy-initialization"; ruleTester.run(RULE_NAME, rule, { - invalid: ([ - ["getValue()", T.CallExpression], - ["getValue(1, 2, 3)", T.CallExpression], - ["new Foo()", T.NewExpression], - ] satisfies [string, T][]).flatMap(([expression, type]) => [ + invalid: [ { - code: `import { useState } from "react"; useState(1 || ${expression})`, + code: `import { useState } from "react"; useState(1 || getValue())`, errors: [ { - type: T.LogicalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(2 < ${expression})`, + code: `import { useState } from "react"; useState(2 < getValue())`, errors: [ { - type: T.BinaryExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(${expression})`, + code: `import { useState } from "react"; useState(1 < 2 ? getValue() : 4)`, errors: [ { - type, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(a ? b : ${expression})`, + code: `import { useState } from "react"; useState(a ? b : getValue())`, errors: [ { - type: T.ConditionalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(${expression} ? b : c)`, + code: `import { useState } from "react"; useState(getValue() ? b : c)`, errors: [ { - type: T.ConditionalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(a ? (b ? ${expression} : b2) : c)`, + code: `import { useState } from "react"; useState(a ? (b ? getValue() : b2) : c)`, errors: [ { - type: T.ConditionalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(${expression} && b)`, + code: `import { useState } from "react"; useState(getValue() && b)`, errors: [ { - type: T.LogicalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(a && ${expression})`, + code: `import { useState } from "react"; useState(a() && new Foo())`, errors: [ { - type: T.LogicalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, - ], - }, - { - code: `import { useState } from "react"; useState(${expression} && b())`, - errors: [ - { - type: T.LogicalExpression, - messageId: "preferUseStateLazyInitialization", - }, - ], - }, - { - code: `import { useState } from "react"; useState(a() && ${expression})`, - errors: [ - { - type: T.LogicalExpression, - messageId: "preferUseStateLazyInitialization", - }, - ], - }, - { - code: `import { useState } from "react"; useState(+${expression})`, - errors: [ { - type: T.UnaryExpression, + type: T.NewExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(-${expression})`, + code: `import { useState } from "react"; useState(+getValue())`, errors: [ { - type: T.UnaryExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(~${expression})`, + code: `import { useState } from "react"; useState(getValue() + 1)`, errors: [ { - type: T.UnaryExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(!${expression})`, + code: `import { useState } from "react"; useState([getValue()])`, errors: [ { - type: T.UnaryExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(${expression} + 1)`, + code: `import { useState } from "react"; useState({ a: getValue() })`, errors: [ { - type: T.BinaryExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], }, { - code: `import { useState } from "react"; useState(${expression} - 1)`, - errors: [ - { - type: T.BinaryExpression, - messageId: "preferUseStateLazyInitialization", - }, - ], - }, - { - code: `import { useState } from "react"; useState([${expression}])`, + code: tsx` + import { useState, use } from 'react'; + + function Component({data}) { + const [data, setData] = useState(data ? use(data) : getValue()); + return null; + } + `, errors: [ { - type: T.ArrayExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], - }, - { - code: `import { useState } from "react"; useState({ a: ${expression} })`, - errors: [ - { - type: T.ObjectExpression, - messageId: "preferUseStateLazyInitialization", + settings: { + "react-x": { + version: "19.0.0", }, - ], + }, }, { - code: tsx`useLocalStorageState(1 || ${expression})`, + code: tsx`useLocalStorageState(1 || getValue())`, errors: [ { - type: T.LogicalExpression, + type: T.CallExpression, messageId: "preferUseStateLazyInitialization", }, ], @@ -188,7 +155,7 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, - ]), + ], valid: [ ...allValid, "useState()", @@ -260,7 +227,11 @@ ruleTester.run(RULE_NAME, rule, { 'const { useState } = require("react"); useState(1 < 2 ? 3 : 4)', 'const { useState } = require("react"); useState(1 == 2 ? 3 : 4)', 'const { useState } = require("react"); useState(1 === 2 ? 3 : 4)', + "const [id, setId] = useState(useId());", "const [state, setState] = useState(use(promise));", + "const [serverData, setLikes] = useState(use(getLikes()));", + "const [data, setData] = useState(use(getData()) || []);", + "const [character, setCharacter] = useState(use(props.character) ?? undefined);", { code: tsx` import { useState, use } from 'react'; @@ -278,55 +249,11 @@ ruleTester.run(RULE_NAME, rule, { }, }, { - code: tsx` - import { useState, use } from 'react'; - - const promise = Promise.resolve(); - - function App() { - const [state, setState] = useState(use(promise)); - - return null; - } - - export default App; - `, - settings: { - "react-x": { - version: "19.0.0", - }, - }, - }, - { - code: "useLocalStorageState()", + code: "useLocalStorage(() => JSON.parse('{}'))", settings: { "react-x": { additionalHooks: { - useState: ["useLocalStorageState"], - }, - }, - }, - }, - { - code: tsx` - import { useState } from 'react'; - - function getValue() { - return 0; - } - - function App() { - const [count, setCount] = useState(() => getValue()); - - return null; - } - - export default App; - `, - settings: { - "react-x": { - additionalHooks: { - useState: ["useLocalStorageState"], + useState: ["useLocalStorage"], }, }, }, diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.ts index 2698d85e7c..be8c5e65d8 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.ts @@ -1,6 +1,12 @@ // Ported from https://github.com/jsx-eslint/eslint-plugin-react/pull/3579/commits/ebb739a0fe99a2ee77055870bfda9f67a2691374 import * as AST from "@eslint-react/ast"; -import { isReactHookCall, isReactHookCallWithNameLoose, isReactHookName, isUseStateCall } from "@eslint-react/core"; +import { + isReactHookCall, + isReactHookCallWithNameLoose, + isReactHookNameLoose, + isUseCall, + isUseStateCall, +} from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/shared"; import { getSettingsFromContext } from "@eslint-react/shared"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; @@ -21,12 +27,7 @@ const ALLOW_LIST = [ "Number", ]; -function isAllowedName(name: string): boolean { - return ALLOW_LIST.includes(name) || isReactHookName(name); -} - // rule takes inspiration from https://github.com/facebook/react/issues/26520 -// TODO: Deprecate this rule when React Compiler is stable enough to be used in production https://github.com/facebook/react/issues/26520#issuecomment-2140795892 export default createRule<[], MessageID>({ meta: { type: "problem", @@ -60,22 +61,25 @@ export function create(context: RuleContext): RuleListener { if (useStateInput == null) { return; } - const nestedCallExpressions = AST.getNestedCallExpressions(useStateInput); - const hasFunctionCall = nestedCallExpressions.some((n) => { - return "name" in n.callee - && !isAllowedName(n.callee.name); - }); - const hasNewCall = AST.getNestedNewExpressions(useStateInput).some((n) => { - return "name" in n.callee - && !isAllowedName(n.callee.name); - }); - if (!hasFunctionCall && !hasNewCall) { - return; + for (const expr of AST.getNestedNewExpressions(useStateInput)) { + if (!("name" in expr.callee)) continue; + if (ALLOW_LIST.includes(expr.callee.name)) continue; + if (AST.findParentNode(expr, (n) => isUseCall(context, n)) != null) continue; + context.report({ + messageId: "preferUseStateLazyInitialization", + node: expr, + }); + } + for (const expr of AST.getNestedCallExpressions(useStateInput)) { + if (!("name" in expr.callee)) continue; + if (isReactHookNameLoose(expr.callee.name)) continue; + if (ALLOW_LIST.includes(expr.callee.name)) continue; + if (AST.findParentNode(expr, (n) => isUseCall(context, n)) != null) continue; + context.report({ + messageId: "preferUseStateLazyInitialization", + node: expr, + }); } - context.report({ - messageId: "preferUseStateLazyInitialization", - node: useStateInput, - }); }, }; }