import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' import { ASTUtils } from '../../utils/ast-utils' import { getDocsUrl } from '../../utils/get-docs-url' import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' import { ExhaustiveDepsUtils } from './exhaustive-deps.utils' import type { TSESLint, TSESTree } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from '../../types' const QUERY_KEY = 'queryKey' const QUERY_FN = 'queryFn' export const name = 'exhaustive-deps' const createRule = ESLintUtils.RuleCreator(getDocsUrl) type RuleOption = { allowlist?: { variables?: Array types?: Array } } export const rule = createRule({ name, meta: { type: 'problem', docs: { description: 'Exhaustive deps rule for useQuery', recommended: 'error', }, messages: { missingDeps: `The following dependencies are missing in your queryKey: {{deps}}`, fixTo: 'Fix to {{result}}', }, hasSuggestions: true, fixable: 'code', schema: [ { type: 'object', properties: { allowlist: { type: 'object', properties: { variables: { type: 'array', items: { type: 'string' } }, types: { type: 'array', items: { type: 'string' } }, }, additionalProperties: false, }, }, additionalProperties: false, }, ], }, defaultOptions: [], create: detectTanstackQueryImports((context) => { return { ObjectExpression: (node: TSESTree.ObjectExpression) => { const scopeManager = context.sourceCode.scopeManager const queryKey = ASTUtils.findPropertyWithIdentifierKey( node.properties, QUERY_KEY, ) const queryFn = ASTUtils.findPropertyWithIdentifierKey( node.properties, QUERY_FN, ) if ( scopeManager === null || queryKey === undefined || queryFn === undefined || !ASTUtils.isNodeOfOneOf(queryFn.value, [ AST_NODE_TYPES.ArrowFunctionExpression, AST_NODE_TYPES.FunctionExpression, AST_NODE_TYPES.ConditionalExpression, ]) ) { return } const queryKeyNode = dereferenceVariablesAndTypeAssertions( queryKey.value, context, ) const queryFnNodes = ExhaustiveDepsUtils.getQueryFnNodes(queryFn) const externalRefs = queryFnNodes.flatMap((fnNode) => ASTUtils.getExternalRefs({ scopeManager, sourceCode: context.sourceCode, node: fnNode, }), ) const relevantRefs = externalRefs.filter((reference) => queryFnNodes.some((fnNode) => ExhaustiveDepsUtils.isRelevantReference({ sourceCode: context.sourceCode, reference, scopeManager, node: fnNode, filename: context.filename, }), ), ) const ruleOptions = context.options.at(0) as RuleOption | undefined const allowlistedVariables = new Set( ruleOptions?.allowlist?.variables ?? [], ) const allowlistedTypes = new Set(ruleOptions?.allowlist?.types ?? []) const requiredRefs = relevantRefs.flatMap((ref) => { if (ref.identifier.type !== AST_NODE_TYPES.Identifier) return [] const refPath = ExhaustiveDepsUtils.computeRefPath({ identifier: ref.identifier, sourceCode: context.sourceCode, }) if (refPath === null) return [] return [ { ...refPath, allowlistedByType: ExhaustiveDepsUtils.variableIsAllowlistedByType({ allowlistedTypes, variable: ref.resolved ?? null, }), }, ] }) if (requiredRefs.length === 0) return const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({ sourceCode: context.sourceCode, scopeManager: scopeManager, queryKeyNode: queryKeyNode, }) const missingPaths = ExhaustiveDepsUtils.computeFilteredMissingPaths({ requiredRefs: requiredRefs, allowlistedVariables: allowlistedVariables, existingRootIdentifiers: queryKeyDeps.roots, existingFullPaths: queryKeyDeps.paths, }) if (missingPaths.length === 0) return const missingAsText = missingPaths.join(', ') const suggestions = buildSuggestions({ queryKeyNode, missingPaths, missingAsText, sourceCode: context.sourceCode, }) context.report({ node, messageId: 'missingDeps', data: { deps: missingAsText }, suggest: suggestions, }) }, } }), }) function buildSuggestions(params: { queryKeyNode: TSESTree.Node missingPaths: Array missingAsText: string sourceCode: Readonly }): TSESLint.ReportSuggestionArray { const { queryKeyNode, missingPaths, missingAsText, sourceCode } = params if (queryKeyNode.type !== AST_NODE_TYPES.ArrayExpression) { return [] } const closingBracket = sourceCode.getLastToken(queryKeyNode) if (!closingBracket) return [] const existingElements = queryKeyNode.elements .filter((el): el is NonNullable => el !== null) .map((el) => sourceCode.getText(el)) const resultText = `[${[...existingElements, ...missingPaths].join(', ')}]` if (queryKeyNode.elements.length === 0) { return [ { messageId: 'fixTo', data: { result: resultText }, fix: (fixer) => fixer.replaceText(queryKeyNode, resultText), }, ] } const tokenBefore = sourceCode.getTokenBefore(closingBracket) const separator = tokenBefore?.value === ',' ? ' ' : ', ' return [ { messageId: 'fixTo', data: { result: resultText }, fix: (fixer) => fixer.insertTextBefore(closingBracket, `${separator}${missingAsText}`), }, ] } function dereferenceVariablesAndTypeAssertions( queryKeyNode: TSESTree.Node, context: Readonly>>, ) { const visitedNodes = new Set() for (let i = 0; i < 1 << 8; ++i) { if (visitedNodes.has(queryKeyNode)) { return queryKeyNode } visitedNodes.add(queryKeyNode) switch (queryKeyNode.type) { case AST_NODE_TYPES.TSAsExpression: queryKeyNode = queryKeyNode.expression break case AST_NODE_TYPES.Identifier: { const expression = ASTUtils.getReferencedExpressionByIdentifier({ context, node: queryKeyNode, }) if (expression == null) { return queryKeyNode } queryKeyNode = expression break } default: return queryKeyNode } } return queryKeyNode }