From 79b459ab834da53adfb7d17b2d1cb63cf6e14ac3 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Mon, 23 Feb 2026 20:51:45 -0800 Subject: [PATCH 01/22] dump --- ...invalidate-queries-no-inline-query.test.ts | 33 ++++++++++++ .../invalidate-queries-no-inline-query.ts | 45 ++++++++++++++++ .../rules/use-query-no-inline-query.test.ts | 34 ++++++++++++ .../src/rules/use-query-no-inline-query.ts | 53 +++++++++++++++++++ packages/eslint-plugin-query/src/utils.ts | 27 ++++++++++ 5 files changed, 192 insertions(+) create mode 100644 packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts create mode 100644 packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts create mode 100644 packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts create mode 100644 packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts create mode 100644 packages/eslint-plugin-query/src/utils.ts diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts new file mode 100644 index 00000000000..20dffb248c0 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts @@ -0,0 +1,33 @@ +import "../_test/setup.js"; + +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import requireQueryOptions from "./invalidate-queries-no-inline-query.js"; + +const ruleTester = new RuleTester({}); + +ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { + valid: [ + { code: `queryClient.invalidateQueries(usersQuery)` }, + { code: `queryClient.invalidateQueries({ ...usersQuery })` }, + { code: `queryClient.invalidateQueries({ ...usersQuery() })` }, + ], + invalid: [ + { + code: `queryClient.invalidateQueries({ queryKey: [] })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `queryClient.invalidateQueries({ queryFn: () => {} })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: "no-inline-query" }], + }, + ], +}); diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts new file mode 100644 index 00000000000..cf906a1f720 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts @@ -0,0 +1,45 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { createRule, isValidQueryNode } from "../utils.js"; + +const name = "invalidate-queries-no-inline-query"; + +export default { + name, + rule: createRule({ + name, + defaultOptions: [], + meta: { + type: "suggestion", + messages: { + "no-inline-query": "Expected query hook to use queryOptions pattern", + }, + docs: { + description: + "Enforces queryClient.invalidateQueries don't have inline queries. Will error if queryKey or queryFn properties are passed to the function", + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + if ( + // check queryClient.invalidateQueries + 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" + ) { + if (!node.arguments[0]) return; + + if (!isValidQueryNode(node.arguments[0])) + context.report({ + messageId: "no-inline-query", + node, + }); + } + }, + }; + }, + }), +}; diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts new file mode 100644 index 00000000000..be9a7f1682c --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts @@ -0,0 +1,34 @@ +import "../_test/setup.js"; + +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import requireQueryOptions from "./use-query-no-inline-query.js"; + +const ruleTester = new RuleTester({}); + +ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { + valid: [ + { code: `useQuery(usersQuery)` }, + { code: `useQuery({ ...usersQuery })` }, + { code: `useQuery({ ...usersQuery() })` }, + { code: `useQuery({ ...usersQuery, meta: {} })` }, + ], + invalid: [ + { + code: `useQuery({ queryKey: [] })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `const users = useQuery({ queryFn: () => {} })`, + errors: [{ messageId: "no-inline-query" }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: "no-inline-query" }], + }, + ], +}); diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts new file mode 100644 index 00000000000..61a2ba8fe8b --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts @@ -0,0 +1,53 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { createRule, isValidQueryNode } from "../utils.js"; + +const useQueryHooks = [ + // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery + "useQuery", + "useQueries", + "useInfiniteQuery", + "useSuspenseQuery", + "useSuspenseQueries", + "useSuspenseInfiniteQuery", +]; + +const name = "use-query-no-inline-query"; + +export default { + name, + rule: createRule({ + name, + defaultOptions: [], + meta: { + type: "suggestion", + messages: { + "no-inline-query": "Expected query hook to use queryOptions pattern", + }, + docs: { + description: + "Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook", + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + if (node.callee.type !== AST_NODE_TYPES.Identifier) return; + if (!useQueryHooks.includes(node.callee.name)) return; + + // use*Query hook call + if (!node.arguments[0]) return; + + // if if caller first argument is an object + const queryNode = node.arguments[0]; + + if (!isValidQueryNode(queryNode)) + context.report({ + messageId: "no-inline-query", + node, + }); + }, + }; + }, + }), +}; diff --git a/packages/eslint-plugin-query/src/utils.ts b/packages/eslint-plugin-query/src/utils.ts new file mode 100644 index 00000000000..4c814bd5199 --- /dev/null +++ b/packages/eslint-plugin-query/src/utils.ts @@ -0,0 +1,27 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + TSESTree, +} from "@typescript-eslint/utils"; + +export const INVALID_QUERY_PROPERTIES = ["queryKey", "queryFn"]; + +export const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/danielpza/eslint-plugin-react-query/docs/rules/${name}.md`, +); + +export function isValidQueryNode(queryNode: TSESTree.Node) { + // we only care about object expressions + if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return true; + + // check if any of the properties is queryKey or queryFn + const hasInvalidProperties = queryNode.properties.find( + (property) => + property.type === AST_NODE_TYPES.Property && + property.key.type === AST_NODE_TYPES.Identifier && + INVALID_QUERY_PROPERTIES.includes(property.key.name), + ); + + return !hasInvalidProperties; +} From 683f291f4eab9b63239b20a4079f477c3214817b Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 10:54:58 -0800 Subject: [PATCH 02/22] dump --- ...invalidate-queries-no-inline-query.test.ts | 21 +++---- .../use-query-no-inline-query.test.ts | 21 +++---- packages/eslint-plugin-query/src/index.ts | 4 ++ packages/eslint-plugin-query/src/rules.ts | 4 ++ .../invalidate-queries-no-inline-query.ts | 45 --------------- ...invalidate-queries-no-inline-query.rule.ts | 48 ++++++++++++++++ .../src/rules/use-query-no-inline-query.ts | 53 ------------------ .../use-query-no-inline-query.rule.ts | 56 +++++++++++++++++++ packages/eslint-plugin-query/src/utils.ts | 27 --------- .../utils/detect-query-options-in-object.ts | 23 ++++++++ 10 files changed, 153 insertions(+), 149 deletions(-) rename packages/eslint-plugin-query/src/{rules => __tests__}/invalidate-queries-no-inline-query.test.ts (54%) rename packages/eslint-plugin-query/src/{rules => __tests__}/use-query-no-inline-query.test.ts (52%) delete mode 100644 packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts create mode 100644 packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts delete mode 100644 packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts create mode 100644 packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts delete mode 100644 packages/eslint-plugin-query/src/utils.ts create mode 100644 packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts similarity index 54% rename from packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts rename to packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts index 20dffb248c0..99119a797c8 100644 --- a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts @@ -1,12 +1,9 @@ -import "../_test/setup.js"; +import { RuleTester } from '@typescript-eslint/rule-tester' +import { rule } from '../rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule' -import { RuleTester } from "@typescript-eslint/rule-tester"; +const ruleTester = new RuleTester() -import requireQueryOptions from "./invalidate-queries-no-inline-query.js"; - -const ruleTester = new RuleTester({}); - -ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { +ruleTester.run('invalidate-queries-no-inline-query', rule, { valid: [ { code: `queryClient.invalidateQueries(usersQuery)` }, { code: `queryClient.invalidateQueries({ ...usersQuery })` }, @@ -15,19 +12,19 @@ ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { invalid: [ { code: `queryClient.invalidateQueries({ queryKey: [] })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `queryClient.invalidateQueries({ queryFn: () => {} })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, ], -}); +}) diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts similarity index 52% rename from packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts rename to packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts index be9a7f1682c..338a0c69030 100644 --- a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts @@ -1,12 +1,9 @@ -import "../_test/setup.js"; +import { RuleTester } from '@typescript-eslint/rule-tester' +import { rule } from '../rules/use-query-no-inline-query/use-query-no-inline-query.rule' -import { RuleTester } from "@typescript-eslint/rule-tester"; +const ruleTester = new RuleTester() -import requireQueryOptions from "./use-query-no-inline-query.js"; - -const ruleTester = new RuleTester({}); - -ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { +ruleTester.run('use-query-no-inline-query', rule, { valid: [ { code: `useQuery(usersQuery)` }, { code: `useQuery({ ...usersQuery })` }, @@ -16,19 +13,19 @@ ruleTester.run(requireQueryOptions.name, requireQueryOptions.rule, { invalid: [ { code: `useQuery({ queryKey: [] })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `const users = useQuery({ queryFn: () => {} })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, { code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: "no-inline-query" }], + errors: [{ messageId: 'no-inline-query' }], }, ], -}); +}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 452bc0f5a42..73acf42af14 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -27,6 +27,8 @@ export const plugin = { '@tanstack/query/infinite-query-property-order': 'error', '@tanstack/query/no-void-query-fn': 'error', '@tanstack/query/mutation-property-order': 'error', + '@tanstack/query/use-query-no-inline-query': 'warn', + '@tanstack/query/invalidate-queries-no-inline-query': 'warn', }, }, 'flat/recommended': [ @@ -43,6 +45,8 @@ export const plugin = { '@tanstack/query/infinite-query-property-order': 'error', '@tanstack/query/no-void-query-fn': 'error', '@tanstack/query/mutation-property-order': 'error', + '@tanstack/query/use-query-no-inline-query': 'warn', + '@tanstack/query/invalidate-queries-no-inline-query': 'warn', }, }, ], diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1d..27817c6008e 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,6 +5,8 @@ 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 useQueryNoInlineQuery from './rules/use-query-no-inline-query/use-query-no-inline-query.rule' +import * as invalidateQueriesNoInlineQuery from './rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule' import type { ESLintUtils } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from './types' @@ -24,4 +26,6 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, + [useQueryNoInlineQuery.name]: useQueryNoInlineQuery.rule, + [invalidateQueriesNoInlineQuery.name]: invalidateQueriesNoInlineQuery.rule, } diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts deleted file mode 100644 index cf906a1f720..00000000000 --- a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AST_NODE_TYPES } from "@typescript-eslint/utils"; -import { createRule, isValidQueryNode } from "../utils.js"; - -const name = "invalidate-queries-no-inline-query"; - -export default { - name, - rule: createRule({ - name, - defaultOptions: [], - meta: { - type: "suggestion", - messages: { - "no-inline-query": "Expected query hook to use queryOptions pattern", - }, - docs: { - description: - "Enforces queryClient.invalidateQueries don't have inline queries. Will error if queryKey or queryFn properties are passed to the function", - }, - schema: [], - }, - create(context) { - return { - CallExpression(node) { - if ( - // check queryClient.invalidateQueries - 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" - ) { - if (!node.arguments[0]) return; - - if (!isValidQueryNode(node.arguments[0])) - context.report({ - messageId: "no-inline-query", - node, - }); - } - }, - }; - }, - }), -}; diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts new file mode 100644 index 00000000000..66be4e333a1 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts @@ -0,0 +1,48 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' +import type { ExtraRuleDocs } from '../../types' + +export const name = 'invalidate-queries-no-inline-query' + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) + +export const rule = createRule({ + name, + meta: { + type: 'suggestion', + docs: { + description: + "Enforces queryClient.invalidateQueries don't have inline queries. Will error if queryKey or queryFn properties are passed to the function", + recommended: 'warn', + }, + messages: { + 'no-inline-query': 'Expected query hook to use queryOptions pattern', + }, + schema: [], + }, + defaultOptions: [], + + create(context) { + return { + CallExpression(node) { + if ( + // check queryClient.invalidateQueries + 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' + ) { + if (!node.arguments[0]) return + + if (detectQueryOptionsInObject(node.arguments[0])) + context.report({ + messageId: 'no-inline-query', + node, + }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts deleted file mode 100644 index 61a2ba8fe8b..00000000000 --- a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { AST_NODE_TYPES } from "@typescript-eslint/utils"; -import { createRule, isValidQueryNode } from "../utils.js"; - -const useQueryHooks = [ - // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery - "useQuery", - "useQueries", - "useInfiniteQuery", - "useSuspenseQuery", - "useSuspenseQueries", - "useSuspenseInfiniteQuery", -]; - -const name = "use-query-no-inline-query"; - -export default { - name, - rule: createRule({ - name, - defaultOptions: [], - meta: { - type: "suggestion", - messages: { - "no-inline-query": "Expected query hook to use queryOptions pattern", - }, - docs: { - description: - "Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook", - }, - schema: [], - }, - create(context) { - return { - CallExpression(node) { - if (node.callee.type !== AST_NODE_TYPES.Identifier) return; - if (!useQueryHooks.includes(node.callee.name)) return; - - // use*Query hook call - if (!node.arguments[0]) return; - - // if if caller first argument is an object - const queryNode = node.arguments[0]; - - if (!isValidQueryNode(queryNode)) - context.report({ - messageId: "no-inline-query", - node, - }); - }, - }; - }, - }), -}; diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts new file mode 100644 index 00000000000..bc3f75917d9 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts @@ -0,0 +1,56 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' +import type { ExtraRuleDocs } from '../../types' + +export const name = 'use-query-no-inline-query' + +const useQueryHooks = [ + // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery + 'useQuery', + 'useQueries', + 'useInfiniteQuery', + 'useSuspenseQuery', + 'useSuspenseQueries', + 'useSuspenseInfiniteQuery', +] + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) + +export const rule = createRule({ + name, + meta: { + type: 'suggestion', + docs: { + description: + 'Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook', + recommended: 'warn', + }, + messages: { + 'no-inline-query': 'Expected query hook to use queryOptions pattern', + }, + schema: [], + }, + defaultOptions: [], + + create(context) { + return { + CallExpression(node) { + if (node.callee.type !== AST_NODE_TYPES.Identifier) return + if (!useQueryHooks.includes(node.callee.name)) return + + // use*Query hook call + if (!node.arguments[0]) return + + // if caller first argument is an object + const queryNode = node.arguments[0] + + if (detectQueryOptionsInObject(queryNode)) + context.report({ + messageId: 'no-inline-query', + node, + }) + }, + } + }, +}) diff --git a/packages/eslint-plugin-query/src/utils.ts b/packages/eslint-plugin-query/src/utils.ts deleted file mode 100644 index 4c814bd5199..00000000000 --- a/packages/eslint-plugin-query/src/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - AST_NODE_TYPES, - ESLintUtils, - TSESTree, -} from "@typescript-eslint/utils"; - -export const INVALID_QUERY_PROPERTIES = ["queryKey", "queryFn"]; - -export const createRule = ESLintUtils.RuleCreator( - (name) => - `https://github.com/danielpza/eslint-plugin-react-query/docs/rules/${name}.md`, -); - -export function isValidQueryNode(queryNode: TSESTree.Node) { - // we only care about object expressions - if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return true; - - // check if any of the properties is queryKey or queryFn - const hasInvalidProperties = queryNode.properties.find( - (property) => - property.type === AST_NODE_TYPES.Property && - property.key.type === AST_NODE_TYPES.Identifier && - INVALID_QUERY_PROPERTIES.includes(property.key.name), - ); - - return !hasInvalidProperties; -} diff --git a/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts b/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts new file mode 100644 index 00000000000..97be69dd40c --- /dev/null +++ b/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.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) { + // skip if it's not an object + if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return true + + // 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 +} From ee810e5c53dbb573b553adf49cb197076c3d5b17 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 10:59:31 -0800 Subject: [PATCH 03/22] fix --- .../src/utils/detect-query-options-in-object.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts b/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts index 97be69dd40c..cd02c53604c 100644 --- a/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts +++ b/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts @@ -9,7 +9,7 @@ const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn'] */ export function detectQueryOptionsInObject(queryNode: TSESTree.Node) { // skip if it's not an object - if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return true + if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return false // check if any of the properties is queryKey or queryFn const hasMainQueryProperties = queryNode.properties.find( From 9025a637c77e5810ae3f34a47e64c085315c73a0 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 10:59:40 -0800 Subject: [PATCH 04/22] update rules --- .../src/__tests__/invalidate-queries-no-inline-query.test.ts | 2 +- .../src/__tests__/use-query-no-inline-query.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts index 99119a797c8..f500c573a90 100644 --- a/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts @@ -3,7 +3,7 @@ import { rule } from '../rules/invalidate-queries-no-inline-query/invalidate-que const ruleTester = new RuleTester() -ruleTester.run('invalidate-queries-no-inline-query', rule, { +ruleTester.run(rule.name, rule, { valid: [ { code: `queryClient.invalidateQueries(usersQuery)` }, { code: `queryClient.invalidateQueries({ ...usersQuery })` }, diff --git a/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts index 338a0c69030..489fdd72f64 100644 --- a/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts @@ -3,7 +3,7 @@ import { rule } from '../rules/use-query-no-inline-query/use-query-no-inline-que const ruleTester = new RuleTester() -ruleTester.run('use-query-no-inline-query', rule, { +ruleTester.run(rule.name, rule, { valid: [ { code: `useQuery(usersQuery)` }, { code: `useQuery({ ...usersQuery })` }, From 9efb25f373c0078ef7aafe17f4f6cb862660fbdc Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:24:02 -0800 Subject: [PATCH 05/22] combine rules --- ...invalidate-queries-no-inline-query.test.ts | 30 ------- .../__tests__/prefer-query-options.test.ts | 60 ++++++++++++++ .../use-query-no-inline-query.test.ts | 31 ------- packages/eslint-plugin-query/src/index.ts | 6 +- packages/eslint-plugin-query/src/rules.ts | 6 +- ...invalidate-queries-no-inline-query.rule.ts | 48 ----------- .../prefer-query-options.rule.ts | 80 +++++++++++++++++++ .../use-query-no-inline-query.rule.ts | 56 ------------- 8 files changed, 144 insertions(+), 173 deletions(-) delete mode 100644 packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts create mode 100644 packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts delete mode 100644 packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts delete mode 100644 packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts create mode 100644 packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts delete mode 100644 packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts diff --git a/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts deleted file mode 100644 index f500c573a90..00000000000 --- a/packages/eslint-plugin-query/src/__tests__/invalidate-queries-no-inline-query.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { RuleTester } from '@typescript-eslint/rule-tester' -import { rule } from '../rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule' - -const ruleTester = new RuleTester() - -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' }], - }, - { - code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], - }, - { - code: `queryClient.invalidateQueries({ queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], - }, - { - code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], - }, - ], -}) 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..4c06c7e7850 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -0,0 +1,60 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' +import { describe } from 'vitest' + +const ruleTester = new RuleTester() + +// 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' }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: 'no-inline-query' }], + }, + { + code: `const users = useQuery({ queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query' }], + }, + { + code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query' }], + }, + ], +}) + +// 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' }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, + errors: [{ messageId: 'no-inline-query' }], + }, + { + code: `queryClient.invalidateQueries({ queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query' }], + }, + { + code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, + errors: [{ messageId: 'no-inline-query' }], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts b/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts deleted file mode 100644 index 489fdd72f64..00000000000 --- a/packages/eslint-plugin-query/src/__tests__/use-query-no-inline-query.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { RuleTester } from '@typescript-eslint/rule-tester' -import { rule } from '../rules/use-query-no-inline-query/use-query-no-inline-query.rule' - -const ruleTester = new RuleTester() - -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' }], - }, - { - code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], - }, - { - code: `const users = useQuery({ queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], - }, - { - code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], - }, - ], -}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 73acf42af14..106fc55b57a 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -27,8 +27,7 @@ export const plugin = { '@tanstack/query/infinite-query-property-order': 'error', '@tanstack/query/no-void-query-fn': 'error', '@tanstack/query/mutation-property-order': 'error', - '@tanstack/query/use-query-no-inline-query': 'warn', - '@tanstack/query/invalidate-queries-no-inline-query': 'warn', + '@tanstack/query/prefer-query-options': 'warn', }, }, 'flat/recommended': [ @@ -45,8 +44,7 @@ export const plugin = { '@tanstack/query/infinite-query-property-order': 'error', '@tanstack/query/no-void-query-fn': 'error', '@tanstack/query/mutation-property-order': 'error', - '@tanstack/query/use-query-no-inline-query': 'warn', - '@tanstack/query/invalidate-queries-no-inline-query': 'warn', + '@tanstack/query/prefer-query-options': 'warn', }, }, ], diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index 27817c6008e..cbae59eb9c9 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,8 +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 useQueryNoInlineQuery from './rules/use-query-no-inline-query/use-query-no-inline-query.rule' -import * as invalidateQueriesNoInlineQuery from './rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.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' @@ -26,6 +25,5 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, - [useQueryNoInlineQuery.name]: useQueryNoInlineQuery.rule, - [invalidateQueriesNoInlineQuery.name]: invalidateQueriesNoInlineQuery.rule, + [preferQueryOptions.name]: preferQueryOptions.rule, } diff --git a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts b/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts deleted file mode 100644 index 66be4e333a1..00000000000 --- a/packages/eslint-plugin-query/src/rules/invalidate-queries-no-inline-query/invalidate-queries-no-inline-query.rule.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' -import { getDocsUrl } from '../../utils/get-docs-url' -import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' -import type { ExtraRuleDocs } from '../../types' - -export const name = 'invalidate-queries-no-inline-query' - -const createRule = ESLintUtils.RuleCreator(getDocsUrl) - -export const rule = createRule({ - name, - meta: { - type: 'suggestion', - docs: { - description: - "Enforces queryClient.invalidateQueries don't have inline queries. Will error if queryKey or queryFn properties are passed to the function", - recommended: 'warn', - }, - messages: { - 'no-inline-query': 'Expected query hook to use queryOptions pattern', - }, - schema: [], - }, - defaultOptions: [], - - create(context) { - return { - CallExpression(node) { - if ( - // check queryClient.invalidateQueries - 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' - ) { - if (!node.arguments[0]) return - - if (detectQueryOptionsInObject(node.arguments[0])) - context.report({ - messageId: 'no-inline-query', - node, - }) - } - }, - } - }, -}) 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..2e6155fdf6a --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -0,0 +1,80 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' +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', + '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: + 'Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook', + recommended: 'warn', + }, + messages: { + 'no-inline-query': 'Expected query hook to use queryOptions pattern', + }, + schema: [], + }, + defaultOptions: [], + + create(context) { + return { + CallExpression(node) { + // use*Query hook call + if (isQueryHookCallExpression(node)) { + const queryNode = node.arguments[0] + if (!queryNode) return + + if (detectQueryOptionsInObject(queryNode)) + context.report({ messageId: 'no-inline-query', node }) + } + + // queryClient.invalidateQueries call + if (isInvalidateQueriesCallExpression(node)) { + if (!node.arguments[0]) return + + if (detectQueryOptionsInObject(node.arguments[0])) + context.report({ messageId: 'no-inline-query', node }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts b/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts deleted file mode 100644 index bc3f75917d9..00000000000 --- a/packages/eslint-plugin-query/src/rules/use-query-no-inline-query/use-query-no-inline-query.rule.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' -import { getDocsUrl } from '../../utils/get-docs-url' -import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' -import type { ExtraRuleDocs } from '../../types' - -export const name = 'use-query-no-inline-query' - -const useQueryHooks = [ - // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery - 'useQuery', - 'useQueries', - 'useInfiniteQuery', - 'useSuspenseQuery', - 'useSuspenseQueries', - 'useSuspenseInfiniteQuery', -] - -const createRule = ESLintUtils.RuleCreator(getDocsUrl) - -export const rule = createRule({ - name, - meta: { - type: 'suggestion', - docs: { - description: - 'Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook', - recommended: 'warn', - }, - messages: { - 'no-inline-query': 'Expected query hook to use queryOptions pattern', - }, - schema: [], - }, - defaultOptions: [], - - create(context) { - return { - CallExpression(node) { - if (node.callee.type !== AST_NODE_TYPES.Identifier) return - if (!useQueryHooks.includes(node.callee.name)) return - - // use*Query hook call - if (!node.arguments[0]) return - - // if caller first argument is an object - const queryNode = node.arguments[0] - - if (detectQueryOptionsInObject(queryNode)) - context.report({ - messageId: 'no-inline-query', - node, - }) - }, - } - }, -}) From bfacdaf579c52a47eb6d7d196fb01a7c1c908fad Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:25:40 -0800 Subject: [PATCH 06/22] small cleanup --- .../rules/prefer-query-options/prefer-query-options.rule.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index 2e6155fdf6a..f090be530da 100644 --- 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 @@ -60,10 +60,9 @@ export const rule = createRule({ CallExpression(node) { // use*Query hook call if (isQueryHookCallExpression(node)) { - const queryNode = node.arguments[0] - if (!queryNode) return + if (!node.arguments[0]) return - if (detectQueryOptionsInObject(queryNode)) + if (detectQueryOptionsInObject(node.arguments[0])) context.report({ messageId: 'no-inline-query', node }) } From 2723b5524d6dd0c071fbf087c2fa6ef2a689c318 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:27:55 -0800 Subject: [PATCH 07/22] update messages --- .../prefer-query-options.rule.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 index f090be530da..00f0e75449f 100644 --- 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 @@ -49,29 +49,32 @@ export const rule = createRule({ recommended: 'warn', }, messages: { - 'no-inline-query': 'Expected query hook to use queryOptions pattern', + '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)) { - if (!node.arguments[0]) return - - if (detectQueryOptionsInObject(node.arguments[0])) - context.report({ messageId: 'no-inline-query', node }) + if ( + isQueryHookCallExpression(node) && + node.arguments[0] && + detectQueryOptionsInObject(node.arguments[0]) + ) { + context.report({ messageId: 'no-inline-query-hook', node }) } // queryClient.invalidateQueries call - if (isInvalidateQueriesCallExpression(node)) { - if (!node.arguments[0]) return - - if (detectQueryOptionsInObject(node.arguments[0])) - context.report({ messageId: 'no-inline-query', node }) + if ( + isInvalidateQueriesCallExpression(node) && + node.arguments[0] && + detectQueryOptionsInObject(node.arguments[0]) + ) { + context.report({ messageId: 'no-inline-query-invalidate', node }) } }, } From cc7aaf442d44984380c3b67563327ecbfffbe424 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:29:03 -0800 Subject: [PATCH 08/22] fix test --- .../src/__tests__/prefer-query-options.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 4c06c7e7850..d0fc5d57ca9 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -15,19 +15,19 @@ ruleTester.run(rule.name, rule, { invalid: [ { code: `useQuery({ queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-hook' }], }, { code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-hook' }], }, { code: `const users = useQuery({ queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-hook' }], }, { code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-hook' }], }, ], }) @@ -42,19 +42,19 @@ ruleTester.run(rule.name, rule, { invalid: [ { code: `queryClient.invalidateQueries({ queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-invalidate' }], }, { code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-invalidate' }], }, { code: `queryClient.invalidateQueries({ queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-invalidate' }], }, { code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, - errors: [{ messageId: 'no-inline-query' }], + errors: [{ messageId: 'no-inline-query-invalidate' }], }, ], }) From 5e3544281721eb0baaf3ad267b2a3b3b443987de Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:29:32 -0800 Subject: [PATCH 09/22] dump --- .../src/__tests__/prefer-query-options.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index d0fc5d57ca9..c54336a2172 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -1,6 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester' import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' -import { describe } from 'vitest' const ruleTester = new RuleTester() From a5c65ec0a12d7c9c1a1a7d329fca595af9273fbd Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:33:03 -0800 Subject: [PATCH 10/22] update changeset --- .changeset/perky-comics-dig.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perky-comics-dig.md 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 From d61a0f46736b287fe4dcf00e18509b9b90930fbb Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:35:35 -0800 Subject: [PATCH 11/22] Update rule description --- .../src/rules/prefer-query-options/prefer-query-options.rule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 00f0e75449f..cca610704c6 100644 --- 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 @@ -45,7 +45,7 @@ export const rule = createRule({ type: 'suggestion', docs: { description: - 'Enforces useQuery (and family) hooks use some form of query constructor pattern. Will error if queryKey or queryFn properties are passed to the hook', + 'Ensures queryOptions constructor pattern is used when calling query apis', recommended: 'warn', }, messages: { From 08dd5409f3d93216e3362c2907c23903b25138e3 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:36:50 -0800 Subject: [PATCH 12/22] move utils to the same folder --- .../rules/prefer-query-options/prefer-query-options.rule.ts | 4 ++-- .../prefer-query-options/prefer-query-options.utils.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/eslint-plugin-query/src/{utils/detect-query-options-in-object.ts => rules/prefer-query-options/prefer-query-options.utils.ts} (100%) 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 index cca610704c6..61fcf24dbe6 100644 --- 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 @@ -3,9 +3,9 @@ import { ESLintUtils, type TSESTree, } from '@typescript-eslint/utils' -import { getDocsUrl } from '../../utils/get-docs-url' -import { detectQueryOptionsInObject } from '../../utils/detect-query-options-in-object' import type { ExtraRuleDocs } from '../../types' +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectQueryOptionsInObject } from './prefer-query-options.utils' export const name = 'prefer-query-options' diff --git a/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts similarity index 100% rename from packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts rename to packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts index cd02c53604c..639fd9f2f3e 100644 --- a/packages/eslint-plugin-query/src/utils/detect-query-options-in-object.ts +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.utils.ts @@ -1,5 +1,5 @@ -import { AST_NODE_TYPES } from '@typescript-eslint/utils' import type { TSESTree } from '@typescript-eslint/utils' +import { AST_NODE_TYPES } from '@typescript-eslint/utils' const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn'] From df68e8bffb064c6dcfe50ca1f7db6409f9438a4c Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:47:49 -0800 Subject: [PATCH 13/22] add recommendedStrict config --- packages/eslint-plugin-query/src/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 106fc55b57a..d4347c113c3 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 } } @@ -36,6 +37,23 @@ export const plugin = { 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', + }, + }, + ], + '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', From 85fb0de624563a7866e60998887baf658b64278c Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:49:15 -0800 Subject: [PATCH 14/22] Fix eslint warnings --- .../prefer-query-options/prefer-query-options.rule.ts | 9 +++------ .../prefer-query-options/prefer-query-options.utils.ts | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) 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 index 61fcf24dbe6..a06c8bf06ab 100644 --- 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 @@ -1,11 +1,8 @@ -import { - AST_NODE_TYPES, - ESLintUtils, - type TSESTree, -} from '@typescript-eslint/utils' -import type { ExtraRuleDocs } from '../../types' +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' 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 index 639fd9f2f3e..cd02c53604c 100644 --- 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 @@ -1,5 +1,5 @@ -import type { TSESTree } from '@typescript-eslint/utils' import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import type { TSESTree } from '@typescript-eslint/utils' const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn'] From a2f221c0de9dc4121e8da202fdb3c47cb439a31c Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:49:57 -0800 Subject: [PATCH 15/22] Fix coderabbit suggestion --- .../rules/prefer-query-options/prefer-query-options.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cd02c53604c..1d6d7d0401f 100644 --- 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 @@ -7,7 +7,7 @@ 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) { +export function detectQueryOptionsInObject(queryNode: TSESTree.Node): boolean { // skip if it's not an object if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return false @@ -19,5 +19,5 @@ export function detectQueryOptionsInObject(queryNode: TSESTree.Node) { MAIN_QUERY_PROPERTIES.includes(property.key.name), ) - return hasMainQueryProperties + return !!hasMainQueryProperties } From 1b25ed1c181be43bd4e3b89dce20b62a0831ab3f Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:52:13 -0800 Subject: [PATCH 16/22] fix coderabbit warning --- .../src/__tests__/prefer-query-options.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index c54336a2172..db323606d68 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -1,8 +1,13 @@ 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: [ From 3fd77658b3fdad73e15fe40d9647471fbcf167b8 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:55:55 -0800 Subject: [PATCH 17/22] remove use*Queries check since it's not working --- .../rules/prefer-query-options/prefer-query-options.rule.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index a06c8bf06ab..6e42a3e91cf 100644 --- 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 @@ -9,10 +9,10 @@ export const name = 'prefer-query-options' const useQueryHooks = [ // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery 'useQuery', - 'useQueries', + // 'useQueries', // only works for single queries for now 'useInfiniteQuery', 'useSuspenseQuery', - 'useSuspenseQueries', + // 'useSuspenseQueries', 'useSuspenseInfiniteQuery', ] From cfaec67c3d9ad3d6ad7cd8a7bac0c3ecaf8a5e30 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 11:58:02 -0800 Subject: [PATCH 18/22] add more tests --- .../src/__tests__/prefer-query-options.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index db323606d68..8c27fddd50e 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -33,6 +33,18 @@ ruleTester.run(rule.name, rule, { 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' }], + }, ], }) From e2e7f1b754c639bb4be23db9715773befc7de574 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 12:02:29 -0800 Subject: [PATCH 19/22] remove rule from recommended config --- packages/eslint-plugin-query/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index d4347c113c3..4848aaaee5c 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -28,7 +28,6 @@ export const plugin = { '@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': 'warn', }, }, 'flat/recommended': [ From 3029fcf3ae68e68951c7936cc2e85c6ee88c93a8 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 12:04:11 -0800 Subject: [PATCH 20/22] fix --- packages/eslint-plugin-query/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 4848aaaee5c..5b1897ebc6a 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -70,5 +70,6 @@ export const plugin = { } satisfies Plugin plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin +plugin.configs['flat/recommendedStrict'][0]!.plugins['@tanstack/query'] = plugin export default plugin From 69d48ea4e0b2d18bea2a80f24620a9a43bb55d46 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 12:06:34 -0800 Subject: [PATCH 21/22] enable rule as error in recommended strict config --- packages/eslint-plugin-query/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 5b1897ebc6a..96847ae1671 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -61,7 +61,7 @@ export const plugin = { '@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': 'warn', + '@tanstack/query/prefer-query-options': 'error', }, }, ], From 15c05dd487972e04cf0af6abaedc44686e3e6cd0 Mon Sep 17 00:00:00 2001 From: Daniel Perez Date: Tue, 24 Feb 2026 12:12:27 -0800 Subject: [PATCH 22/22] update --- .../src/rules/prefer-query-options/prefer-query-options.rule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6e42a3e91cf..d070de0ea75 100644 --- 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 @@ -43,7 +43,7 @@ export const rule = createRule({ docs: { description: 'Ensures queryOptions constructor pattern is used when calling query apis', - recommended: 'warn', + recommended: 'strict', }, messages: { 'no-inline-query-hook': 'Expected query hook to use queryOptions pattern',