@tanstack/eslint-plugin-query
Version:
ESLint plugin for TanStack Query
185 lines (159 loc) • 5.57 kB
text/typescript
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { uniqueBy } from '../../utils/unique-by'
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<ExtraRuleDocs>(getDocsUrl)
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: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context) => {
return {
Property: (node) => {
if (
!ASTUtils.isObjectExpression(node.parent) ||
!ASTUtils.isIdentifierWithName(node.key, QUERY_KEY)
) {
return
}
const scopeManager = context.sourceCode.scopeManager
const queryKey = ASTUtils.findPropertyWithIdentifierKey(
node.parent.properties,
QUERY_KEY,
)
const queryFn = ASTUtils.findPropertyWithIdentifierKey(
node.parent.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
}
let queryKeyNode = queryKey.value
if (
queryKeyNode.type === AST_NODE_TYPES.TSAsExpression &&
queryKeyNode.expression.type === AST_NODE_TYPES.ArrayExpression
) {
queryKeyNode = queryKeyNode.expression
}
if (queryKeyNode.type === AST_NODE_TYPES.Identifier) {
const expression = ASTUtils.getReferencedExpressionByIdentifier({
context,
node: queryKeyNode,
})
if (expression?.type === AST_NODE_TYPES.ArrayExpression) {
queryKeyNode = expression
}
}
const externalRefs = ASTUtils.getExternalRefs({
scopeManager,
sourceCode: context.sourceCode,
node: getQueryFnRelevantNode(queryFn),
})
const relevantRefs = externalRefs.filter((reference) =>
ExhaustiveDepsUtils.isRelevantReference({
sourceCode: context.sourceCode,
reference,
scopeManager,
node: getQueryFnRelevantNode(queryFn),
}),
)
const existingKeys = ASTUtils.getNestedIdentifiers(queryKeyNode).map(
(identifier) =>
ASTUtils.mapKeyNodeToBaseText(identifier, context.sourceCode),
)
const missingRefs = relevantRefs
.map((ref) => ({
ref: ref,
text: ASTUtils.mapKeyNodeToBaseText(
ref.identifier,
context.sourceCode,
),
}))
.filter(({ ref, text }) => {
return (
!ref.isTypeReference &&
!ASTUtils.isAncestorIsCallee(ref.identifier) &&
!existingKeys.some((existingKey) => existingKey === text) &&
!existingKeys.includes(text.split(/[?.]/)[0] ?? '')
)
})
.map(({ ref, text }) => ({
identifier: ref.identifier,
text: text,
}))
const uniqueMissingRefs = uniqueBy(missingRefs, (x) => x.text)
if (uniqueMissingRefs.length > 0) {
const missingAsText = uniqueMissingRefs
.map((ref) =>
ASTUtils.mapKeyNodeToText(ref.identifier, context.sourceCode),
)
.join(', ')
const queryKeyValue = context.sourceCode.getText(queryKeyNode)
const existingWithMissing =
queryKeyValue === '[]'
? `[${missingAsText}]`
: queryKeyValue.replace(/\]$/, `, ${missingAsText}]`)
const suggestions: TSESLint.ReportSuggestionArray<string> = []
if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) {
suggestions.push({
messageId: 'fixTo',
data: { result: existingWithMissing },
fix(fixer) {
return fixer.replaceText(queryKeyNode, existingWithMissing)
},
})
}
context.report({
node: node,
messageId: 'missingDeps',
data: {
deps: uniqueMissingRefs.map((ref) => ref.text).join(', '),
},
suggest: suggestions,
})
}
},
}
}),
})
function getQueryFnRelevantNode(queryFn: TSESTree.Property) {
if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) {
return queryFn.value
}
if (
queryFn.value.consequent.type === AST_NODE_TYPES.Identifier &&
queryFn.value.consequent.name === 'skipToken'
) {
return queryFn.value.alternate
}
return queryFn.value.consequent
}