diff --git a/.gitignore b/.gitignore index 861f3e62..140152d0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ coverage # ignore non-pnpm lockfiles package-lock.json yarn.lock + +.eslint-config-inspector diff --git a/eslint.config.js b/eslint.config.js index a0329a49..a5c9ea03 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ require('tsx/cjs') const library = require('./src') module.exports = [ - ...library.recommendedFlatConfigs, + ...library.recommendedReactConfigs, ...library.crazyConfigs, // experimental stuff ] diff --git a/package.json b/package.json index 40287759..33d9e9f3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@eslint/config-inspector": "0.5.4", "@types/lodash": "^4.17.7", "@types/node": "20.16.5", + "@types/react": "18.0.0", + "dedent": "1.6.0", "eslint": "^8.57.0", "execa": "^9.0.0", "link": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1211d663..1a48aee0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,12 @@ importers: '@types/node': specifier: 20.16.5 version: 20.16.5 + '@types/react': + specifier: 18.0.0 + version: 18.0.0 + dedent: + specifier: 1.6.0 + version: 1.6.0 eslint: specifier: ^8.57.0 version: 8.57.1 @@ -945,6 +951,15 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/prop-types@15.7.14': + resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} + + '@types/react@18.0.0': + resolution: {integrity: sha512-7+K7zEQYu7NzOwQGLR91KwWXXDzmTFODRVizJyIALf6RfLv2GDpqpknX64pvRVILXCpXi7O/pua8NGk44dLvJw==} + + '@types/scheduler@0.26.0': + resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} + '@types/semver@7.5.5': resolution: {integrity: sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==} @@ -1627,6 +1642,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4909,6 +4927,16 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/prop-types@15.7.14': {} + + '@types/react@18.0.0': + dependencies: + '@types/prop-types': 15.7.14 + '@types/scheduler': 0.26.0 + csstype: 3.1.3 + + '@types/scheduler@0.26.0': {} + '@types/semver@7.5.5': {} '@types/semver@7.5.8': {} @@ -5703,6 +5731,8 @@ snapshots: css-what@6.1.0: {} + csstype@3.1.3: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.1: diff --git a/src/index.ts b/src/index.ts index 7fb7e583..315f5349 100644 --- a/src/index.ts +++ b/src/index.ts @@ -645,8 +645,8 @@ export const recommendedReactConfigs = [ ...configs.globals_react, ...configs.reactRecommended, ...configs.reactHooks, - ...configs.jsxA11y, ...configs.reactHooksRecommended, + ...configs.jsxA11y, ...configs.jsxA11yRecommended, {settings: {react: {version: '18'}}}, ...jsxStyleConfigs, diff --git a/src/shim-react-hooks.ts b/src/shim-react-hooks.ts index 843e244e..c8e980d9 100644 --- a/src/shim-react-hooks.ts +++ b/src/shim-react-hooks.ts @@ -35,7 +35,7 @@ export const getShimmedReactHooks = () => { parts[1] if (process.env.DEBUG_REACT_HOOKS_HACK) { - // to debug this code-shimming, write the code to a file so we can look at it using eyeballs + // to debug this code-shimming, write the code to a file so we can look at it with our eyeballs const changedPath = reactHooksPluginPath + '.changed.js' fs.writeFileSync(changedPath, newReactHooksPluginCode) // eslint-disable-next-line no-console @@ -48,5 +48,49 @@ export const getShimmedReactHooks = () => { throw new Error(`Failed to shim react-hooks plugin. Exports: ${JSON.stringify(Object.keys(exports))}`) } + // make rules-of-hooks understand trpc.foo.useQuery() calls + const originalRule = exports.rules['rules-of-hooks'] as import('eslint').Rule.RuleModule + exports.rules['rules-of-hooks'] = { + create: (context, ...args) => { + const originalRuleListener = originalRule.create(context, ...args) + return Object.fromEntries( + Object.keys(originalRuleListener).map(k => { + if (k === 'CallExpression') { + // HACK: Just for this listener of this rule, pretend CallExpressions like trpc.foo.useQuery() look like Trpc.useQuery() because rules-of-hooks considers that a hook 🤷 + // This should just be made configurable in eslint-plugin-react-hooks, but no movement on the issue for this: https://github.com/facebook/react/issues/25065. For now this works. + return [ + k, + (node: import('eslint').Rule.Node) => { + if (!('callee' in node)) + throw new Error(`Expected node to have a callee property, but got ${JSON.stringify(node.type)}`) + const calleeProxy = new Proxy(node.callee, { + get(calleeTarget, calleeProp, calleeReceiver) { + if (calleeProp === 'object') { + return {type: 'Identifier', name: 'Trpc'} + } + + return Reflect.get(calleeTarget, calleeProp, calleeReceiver) as {} + }, + }) + const nodeProxy = new Proxy(node, { + get(target, prop, receiver) { + if (prop === 'callee') { + return calleeProxy + } + + return Reflect.get(target, prop, receiver) as {} + }, + }) + + return originalRuleListener[k]?.(nodeProxy) + }, + ] + } + return [k, originalRuleListener[k]] + }), + ) + }, + } satisfies import('eslint').Rule.RuleModule + return exports as typeof import('eslint-plugin-react-hooks') } diff --git a/test/index.test.js b/test/index.test.js index f4a5da5b..80120eb8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,3 +1,4 @@ +import dedent from 'dedent' import {execa} from 'execa' import * as fs from 'fs' import * as path from 'path' @@ -27,3 +28,88 @@ test('fix works', async () => { fs.rmSync(testdir, {recursive: true}) }, 10_000) + +test('rules-of-hooks shim', async () => { + const testdir = path.join(__dirname, 'ignoreme') + + const testfile = path.join(testdir, 'testfile.tsx') + + fs.mkdirSync(testdir, {recursive: true}) + fs.readdirSync(testdir).forEach(file => fs.unlinkSync(path.join(testdir, file))) + fs.writeFileSync( + testfile, + dedent` + import React from 'react' + + const useMutation = () => ({mutate: () => {}}) + export const X = () => { + if (Math.random()) { + useMutation() + } + + return
Hello
+ } + + const trpc = {foo: {bar: {useMutation}}} + + export const Y = () => { + if (Math.random()) { + trpc.foo.bar.useMutation() + } + + return
Hello
+ } + `, + ) + + const {all} = await execa('pnpm', ['eslint', 'test/ignoreme/*', '--fix', '--no-ignore'], {all: true, reject: false}) + + expect(all).toContain(`React Hook "useMutation" is called conditionally`) + expect(all).toContain(`React Hook "trpc.foo.bar.useMutation" is called conditionally`) + expect(all.replace(process.cwd(), '')).toMatchInlineSnapshot(` + " + /test/ignoreme/testfile.tsx + 6:5 error React Hook "useMutation" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks + 16:5 error React Hook "trpc.foo.bar.useMutation" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks + + ✖ 2 problems (2 errors, 0 warnings) + " + `) + + fs.rmSync(testdir, {recursive: true}) +}) + +test('exhaustive-deps shim', async () => { + const testdir = path.join(__dirname, 'ignoreme') + + const testfile = path.join(testdir, 'testfile.tsx') + + fs.mkdirSync(testdir, {recursive: true}) + fs.readdirSync(testdir).forEach(file => fs.unlinkSync(path.join(testdir, file))) + fs.writeFileSync( + testfile, + dedent` + import React from 'react' + + const useMutation = () => ({mutate: () => {}}) + export const X = () => { + const mutation = useMutation() + React.useEffect(() => { + mutation.mutate() // this should be ok, because of our shim + }, [mutation.mutate]) + + React.useEffect(() => { + alert(mutation.status) // this is not ok, we need to add mutation.status to the deps array + }, [mutation.mutate]) + + return
Hello
+ } + `, + ) + + const {all} = await execa('pnpm', ['eslint', 'test/ignoreme/*', '--fix', '--no-ignore'], {all: true, reject: false}) + + expect(all.replace(process.cwd(), '')).toMatchInlineSnapshot(`""`) + + fs.rmSync(testdir, {recursive: true}) +}) diff --git a/tsconfig.json b/tsconfig.json index 4079792d..68d643c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "allowJs": true, "resolveJsonModule": true, "skipLibCheck": true, + "jsx": "react-jsx", "lib": [ "es2017", "DOM"