diff --git a/.changeset/perky-comics-dig.md b/.changeset/perky-comics-dig.md new file mode 100644 index 00000000000..c2bbf82033e --- /dev/null +++ b/.changeset/perky-comics-dig.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-query': minor +--- + +Add prefer-query-options rule diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts new file mode 100644 index 00000000000..8c27fddd50e --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -0,0 +1,76 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' +import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' + +const ruleTester = new RuleTester() + +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + +// useQuery hooks +ruleTester.run(rule.name, rule, { + valid: [ + { code: `useQuery(usersQuery)` }, + { code: `useQuery({ ...usersQuery })` }, + { code: `useQuery({ ...usersQuery() })` }, + { code: `useQuery({ ...usersQuery, meta: {} })` }, + ], + invalid: [ + { + code: `useQuery({ queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `const users = useQuery({ queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `useInfiniteQuery({ queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `useSuspenseQuery({ queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + { + code: `useSuspenseInfiniteQuery({ queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-hook' }], + }, + ], +}) + +// queryClient.invalidateQueries expressions +ruleTester.run(rule.name, rule, { + valid: [ + { code: `queryClient.invalidateQueries(usersQuery)` }, + { code: `queryClient.invalidateQueries({ ...usersQuery })` }, + { code: `queryClient.invalidateQueries({ ...usersQuery() })` }, + ], + invalid: [ + { + code: `queryClient.invalidateQueries({ queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-invalidate' }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: 'no-inline-query-invalidate' }], + }, + { + code: `queryClient.invalidateQueries({ queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query-invalidate' }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query-invalidate' }], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 452bc0f5a42..96847ae1671 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -9,6 +9,7 @@ export interface Plugin extends Omit { configs: { recommended: ESLint.ConfigData 'flat/recommended': Array + 'flat/recommendedStrict': Array } } @@ -46,10 +47,29 @@ export const plugin = { }, }, ], + 'flat/recommendedStrict': [ + { + name: 'tanstack/query/flat/recommendedStrict', + plugins: { + '@tanstack/query': {}, // Assigned after plugin object created + }, + rules: { + '@tanstack/query/exhaustive-deps': 'error', + '@tanstack/query/no-rest-destructuring': 'warn', + '@tanstack/query/stable-query-client': 'error', + '@tanstack/query/no-unstable-deps': 'error', + '@tanstack/query/infinite-query-property-order': 'error', + '@tanstack/query/no-void-query-fn': 'error', + '@tanstack/query/mutation-property-order': 'error', + '@tanstack/query/prefer-query-options': 'error', + }, + }, + ], }, rules, } satisfies Plugin plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin +plugin.configs['flat/recommendedStrict'][0]!.plugins['@tanstack/query'] = plugin export default plugin diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1d..cbae59eb9c9 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,6 +5,7 @@ import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule' import * as infiniteQueryPropertyOrder from './rules/infinite-query-property-order/infinite-query-property-order.rule' import * as noVoidQueryFn from './rules/no-void-query-fn/no-void-query-fn.rule' import * as mutationPropertyOrder from './rules/mutation-property-order/mutation-property-order.rule' +import * as preferQueryOptions from './rules/prefer-query-options/prefer-query-options.rule' import type { ESLintUtils } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from './types' @@ -24,4 +25,5 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, + [preferQueryOptions.name]: preferQueryOptions.rule, } diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts new file mode 100644 index 00000000000..d070de0ea75 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -0,0 +1,79 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectQueryOptionsInObject } from './prefer-query-options.utils' +import type { TSESTree } from '@typescript-eslint/utils' +import type { ExtraRuleDocs } from '../../types' + +export const name = 'prefer-query-options' + +const useQueryHooks = [ + // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery + 'useQuery', + // 'useQueries', // only works for single queries for now + 'useInfiniteQuery', + 'useSuspenseQuery', + // 'useSuspenseQueries', + 'useSuspenseInfiniteQuery', +] + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) + +/** @returns true if it's a `useQuery` hook call expression node */ +function isQueryHookCallExpression(node: TSESTree.CallExpression) { + if (node.callee.type !== AST_NODE_TYPES.Identifier) return false + if (!useQueryHooks.includes(node.callee.name)) return false + return true +} + +/** @returns true if it's a call to `queryClient.invalidateQueries` */ +function isInvalidateQueriesCallExpression(node: TSESTree.CallExpression) { + return ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === 'queryClient' && + node.callee.property.type === AST_NODE_TYPES.Identifier && + node.callee.property.name === 'invalidateQueries' + ) +} + +export const rule = createRule({ + name, + meta: { + type: 'suggestion', + docs: { + description: + 'Ensures queryOptions constructor pattern is used when calling query apis', + recommended: 'strict', + }, + messages: { + 'no-inline-query-hook': 'Expected query hook to use queryOptions pattern', + 'no-inline-query-invalidate': + 'Expected query invalidate call to use queryOptions pattern', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + // use*Query hook call + if ( + isQueryHookCallExpression(node) && + node.arguments[0] && + detectQueryOptionsInObject(node.arguments[0]) + ) { + context.report({ messageId: 'no-inline-query-hook', node }) + } + + // queryClient.invalidateQueries call + if ( + isInvalidateQueriesCallExpression(node) && + node.arguments[0] && + detectQueryOptionsInObject(node.arguments[0]) + ) { + context.report({ messageId: 'no-inline-query-invalidate', node }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts new file mode 100644 index 00000000000..1d6d7d0401f --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts @@ -0,0 +1,23 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import type { TSESTree } from '@typescript-eslint/utils' + +const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn'] + +/** + * @returns true if the node is an object that has main query options (ie queryKey or queryFn). + * This is used for detecting inline query options in hooks and functions + */ +export function detectQueryOptionsInObject(queryNode: TSESTree.Node): boolean { + // skip if it's not an object + if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return false + + // check if any of the properties is queryKey or queryFn + const hasMainQueryProperties = queryNode.properties.find( + (property) => + property.type === AST_NODE_TYPES.Property && + property.key.type === AST_NODE_TYPES.Identifier && + MAIN_QUERY_PROPERTIES.includes(property.key.name), + ) + + return !!hasMainQueryProperties +}