|
| 1 | +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' |
| 2 | +import { getDocsUrl } from '../../utils/get-docs-url' |
| 3 | +import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' |
| 4 | +import type { TSESTree } from '@typescript-eslint/utils' |
| 5 | +import type { ExtraRuleDocs } from '../../types' |
| 6 | + |
| 7 | +export const name = 'no-unstable-deps' |
| 8 | + |
| 9 | +export const reactHookNames = ['useEffect', 'useCallback', 'useMemo'] |
| 10 | +export const useQueryHookNames = [ |
| 11 | + 'useQuery', |
| 12 | + 'useSuspenseQuery', |
| 13 | + 'useQueries', |
| 14 | + 'useSuspenseQueries', |
| 15 | + 'useInfiniteQuery', |
| 16 | + 'useSuspenseInfiniteQuery', |
| 17 | +] |
| 18 | +const allHookNames = ['useMutation', ...useQueryHookNames] |
| 19 | +const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl) |
| 20 | + |
| 21 | +export const rule = createRule({ |
| 22 | + name, |
| 23 | + meta: { |
| 24 | + type: 'problem', |
| 25 | + docs: { |
| 26 | + description: |
| 27 | + 'Disallow putting the result of useMutation directly in a React hook dependency array', |
| 28 | + recommended: 'error', |
| 29 | + }, |
| 30 | + messages: { |
| 31 | + noUnstableDeps: `The result of {{queryHook}} is not referentially stable, so don't pass it directly into the dependencies array of {{reactHook}}. Instead, destructure the return value of {{queryHook}} and pass the destructured values into the dependency array of {{reactHook}}.`, |
| 32 | + }, |
| 33 | + schema: [], |
| 34 | + }, |
| 35 | + defaultOptions: [], |
| 36 | + |
| 37 | + create: detectTanstackQueryImports((context) => { |
| 38 | + const trackedVariables: Record<string, string> = {} |
| 39 | + const hookAliasMap: Record<string, string> = {} |
| 40 | + |
| 41 | + function getReactHook(node: TSESTree.CallExpression): string | undefined { |
| 42 | + if (node.callee.type === 'Identifier') { |
| 43 | + const calleeName = node.callee.name |
| 44 | + // Check if the identifier is a known React hook or an alias |
| 45 | + if (reactHookNames.includes(calleeName) || calleeName in hookAliasMap) { |
| 46 | + return calleeName |
| 47 | + } |
| 48 | + } else if ( |
| 49 | + node.callee.type === 'MemberExpression' && |
| 50 | + node.callee.object.type === 'Identifier' && |
| 51 | + node.callee.object.name === 'React' && |
| 52 | + node.callee.property.type === 'Identifier' && |
| 53 | + reactHookNames.includes(node.callee.property.name) |
| 54 | + ) { |
| 55 | + // Member expression case: `React.useCallback` |
| 56 | + return node.callee.property.name |
| 57 | + } |
| 58 | + return undefined |
| 59 | + } |
| 60 | + |
| 61 | + function collectVariableNames( |
| 62 | + pattern: TSESTree.BindingName, |
| 63 | + queryHook: string, |
| 64 | + ) { |
| 65 | + if (pattern.type === AST_NODE_TYPES.Identifier) { |
| 66 | + trackedVariables[pattern.name] = queryHook |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + return { |
| 71 | + ImportDeclaration(node: TSESTree.ImportDeclaration) { |
| 72 | + if ( |
| 73 | + node.specifiers.length > 0 && |
| 74 | + node.importKind === 'value' && |
| 75 | + node.source.value === 'React' |
| 76 | + ) { |
| 77 | + node.specifiers.forEach((specifier) => { |
| 78 | + if ( |
| 79 | + specifier.type === AST_NODE_TYPES.ImportSpecifier && |
| 80 | + reactHookNames.includes(specifier.imported.name) |
| 81 | + ) { |
| 82 | + // Track alias or direct import |
| 83 | + hookAliasMap[specifier.local.name] = specifier.imported.name |
| 84 | + } |
| 85 | + }) |
| 86 | + } |
| 87 | + }, |
| 88 | + |
| 89 | + VariableDeclarator(node) { |
| 90 | + if ( |
| 91 | + node.init !== null && |
| 92 | + node.init.type === AST_NODE_TYPES.CallExpression && |
| 93 | + node.init.callee.type === AST_NODE_TYPES.Identifier && |
| 94 | + allHookNames.includes(node.init.callee.name) |
| 95 | + ) { |
| 96 | + collectVariableNames(node.id, node.init.callee.name) |
| 97 | + } |
| 98 | + }, |
| 99 | + CallExpression: (node) => { |
| 100 | + const reactHook = getReactHook(node) |
| 101 | + if ( |
| 102 | + reactHook !== undefined && |
| 103 | + node.arguments.length > 1 && |
| 104 | + node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression |
| 105 | + ) { |
| 106 | + const depsArray = node.arguments[1].elements |
| 107 | + depsArray.forEach((dep) => { |
| 108 | + if ( |
| 109 | + dep !== null && |
| 110 | + dep.type === AST_NODE_TYPES.Identifier && |
| 111 | + trackedVariables[dep.name] !== undefined |
| 112 | + ) { |
| 113 | + const queryHook = trackedVariables[dep.name] |
| 114 | + context.report({ |
| 115 | + node: dep, |
| 116 | + messageId: 'noUnstableDeps', |
| 117 | + data: { |
| 118 | + queryHook, |
| 119 | + reactHook, |
| 120 | + }, |
| 121 | + }) |
| 122 | + } |
| 123 | + }) |
| 124 | + } |
| 125 | + }, |
| 126 | + } |
| 127 | + }), |
| 128 | +}) |
0 commit comments