diff --git a/fixtures/eslint-v10/README.md b/fixtures/eslint-v10/README.md new file mode 100644 index 000000000000..7b52e241f22b --- /dev/null +++ b/fixtures/eslint-v10/README.md @@ -0,0 +1,12 @@ +# ESLint v10 Fixture + +This fixture allows us to test e2e functionality for `eslint-plugin-react-hooks` with eslint version 10. + +Run the following to test. + +```sh +cd fixtures/eslint-v10 +yarn +yarn build +yarn lint +``` diff --git a/fixtures/eslint-v10/build.mjs b/fixtures/eslint-v10/build.mjs new file mode 100644 index 000000000000..e0dd355ba439 --- /dev/null +++ b/fixtures/eslint-v10/build.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import {execSync} from 'node:child_process'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +execSync('yarn build -r stable eslint-plugin-react-hooks', { + cwd: resolve(__dirname, '..', '..'), + stdio: 'inherit', +}); diff --git a/fixtures/eslint-v10/eslint.config.ts b/fixtures/eslint-v10/eslint.config.ts new file mode 100644 index 000000000000..f7d0bddac443 --- /dev/null +++ b/fixtures/eslint-v10/eslint.config.ts @@ -0,0 +1,20 @@ +import {defineConfig} from 'eslint/config'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default defineConfig([ + reactHooks.configs.flat['recommended-latest'], + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + 'react-hooks/exhaustive-deps': 'error', + }, + }, +]); diff --git a/fixtures/eslint-v10/index.js b/fixtures/eslint-v10/index.js new file mode 100644 index 000000000000..601b68a0328c --- /dev/null +++ b/fixtures/eslint-v10/index.js @@ -0,0 +1,182 @@ +/** + * Exhaustive Deps + */ +// Valid because dependencies are declared correctly +function Comment({comment, commentSource}) { + const currentUserID = comment.viewer.id; + const environment = RelayEnvironment.forUser(currentUserID); + const commentID = nullthrows(comment.id); + useEffect(() => { + const subscription = SubscriptionCounter.subscribeOnce( + `StoreSubscription_${commentID}`, + () => + StoreSubscription.subscribe( + environment, + { + comment_id: commentID, + }, + currentUserID, + commentSource + ) + ); + return () => subscription.dispose(); + }, [commentID, commentSource, currentUserID, environment]); +} + +// Valid because no dependencies +function UseEffectWithNoDependencies() { + const local = {}; + useEffect(() => { + console.log(local); + }); +} +function UseEffectWithEmptyDependencies() { + useEffect(() => { + const local = {}; + console.log(local); + }, []); +} + +// OK because `props` wasn't defined. +function ComponentWithNoPropsDefined() { + useEffect(() => { + console.log(props.foo); + }, []); +} + +// Valid because props are declared as a dependency +function ComponentWithPropsDeclaredAsDep({foo}) { + useEffect(() => { + console.log(foo.length); + console.log(foo.slice(0)); + }, [foo]); +} + +// Valid because individual props are declared as dependencies +function ComponentWithIndividualPropsDeclaredAsDeps(props) { + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + }, [props.bar, props.foo]); +} + +// Invalid because neither props or props.foo are declared as dependencies +function ComponentWithoutDeclaringPropAsDep(props) { + useEffect(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useCallback(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // eslint-disable-next-line react-hooks/void-use-memo + useMemo(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useCallback(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // eslint-disable-next-line react-hooks/void-use-memo + React.useMemo(() => { + console.log(props.foo); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); // This one isn't a violation +} + +/** + * Rules of Hooks + */ +// Valid because functions can call functions. +function normalFunctionWithConditionalFunction() { + if (cond) { + doSomething(); + } +} + +// Valid because hooks can call hooks. +function useHook() { + useState(); +} +const whatever = function useHook() { + useState(); +}; +const useHook1 = () => { + useState(); +}; +let useHook2 = () => useState(); +useHook2 = () => { + useState(); +}; + +// Invalid because hooks can't be called in conditionals. +function ComponentWithConditionalHook() { + if (cond) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useConditionalHook(); + } +} + +// Invalid because hooks can't be called in loops. +function useHookInLoops() { + while (a) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useHook1(); + if (b) return; + // eslint-disable-next-line react-hooks/rules-of-hooks + useHook2(); + } + while (c) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useHook3(); + if (d) return; + // eslint-disable-next-line react-hooks/rules-of-hooks + useHook4(); + } +} + +/** + * Compiler Rules + */ +// Invalid: component factory +function InvalidComponentFactory() { + const DynamicComponent = () =>
Hello
; + // eslint-disable-next-line react-hooks/static-components + return ; +} + +// Invalid: mutating globals +function InvalidGlobals() { + // eslint-disable-next-line react-hooks/immutability + window.myGlobal = 42; + return
Done
; +} + +// Invalid: useMemo with wrong deps +function InvalidUseMemo({items}) { + // eslint-disable-next-line react-hooks/exhaustive-deps + const sorted = useMemo(() => [...items].sort(), []); + return
{sorted.length}
; +} + +// Invalid: missing/extra deps in useEffect +function InvalidEffectDeps({a, b}) { + useEffect(() => { + console.log(a); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + console.log(a); + // TODO: eslint-disable-next-line react-hooks/exhaustive-effect-dependencies + }, [a, b]); +} diff --git a/fixtures/eslint-v10/package.json b/fixtures/eslint-v10/package.json new file mode 100644 index 000000000000..ee5cb95e02b4 --- /dev/null +++ b/fixtures/eslint-v10/package.json @@ -0,0 +1,16 @@ +{ + "private": true, + "name": "eslint-v10", + "dependencies": { + "eslint": "^10.0.0", + "eslint-plugin-react-hooks": "link:../../build/oss-stable/eslint-plugin-react-hooks", + "jiti": "^2.4.2" + }, + "scripts": { + "build": "node build.mjs && yarn", + "lint": "tsc --noEmit && eslint index.js --report-unused-disable-directives" + }, + "devDependencies": { + "typescript": "^5.4.3" + } +} diff --git a/fixtures/eslint-v10/tsconfig.json b/fixtures/eslint-v10/tsconfig.json new file mode 100644 index 000000000000..b81887fc175b --- /dev/null +++ b/fixtures/eslint-v10/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": [ + "es2022" + ], + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2022", + "typeRoots": [ + "./node_modules/@types" + ], + "skipLibCheck": true + }, + "exclude": [ + "node_modules", + "**/node_modules", + "../node_modules", + "../../node_modules" + ] +} diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json index 9a8f8ac353ff..3e436421b3d3 100644 --- a/packages/eslint-plugin-react-hooks/package.json +++ b/packages/eslint-plugin-react-hooks/package.json @@ -36,7 +36,7 @@ }, "homepage": "https://react.dev/", "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" }, "dependencies": { "@babel/core": "^7.24.4",