Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ coverage
# ignore non-pnpm lockfiles
package-lock.json
yarn.lock

.eslint-config-inspector
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ require('tsx/cjs')
const library = require('./src')

module.exports = [
...library.recommendedFlatConfigs,
...library.recommendedReactConfigs,
...library.crazyConfigs, // experimental stuff
]

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 45 additions & 1 deletion src/shim-react-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
}
86 changes: 86 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dedent from 'dedent'
import {execa} from 'execa'
import * as fs from 'fs'
import * as path from 'path'
Expand Down Expand Up @@ -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 <div>Hello</div>
}

const trpc = {foo: {bar: {useMutation}}}

export const Y = () => {
if (Math.random()) {
trpc.foo.bar.useMutation()
}

return <div>Hello</div>
}
`,
)

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(), '<dir>')).toMatchInlineSnapshot(`
"
<dir>/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 <div>Hello</div>
}
`,
)

const {all} = await execa('pnpm', ['eslint', 'test/ignoreme/*', '--fix', '--no-ignore'], {all: true, reject: false})

expect(all.replace(process.cwd(), '<dir>')).toMatchInlineSnapshot(`""`)

fs.rmSync(testdir, {recursive: true})
})
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"allowJs": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"lib": [
"es2017",
"DOM"
Expand Down
Loading