From fc06643ce31da56e6c768b5645417204cd212e19 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Sun, 24 May 2026 17:13:56 +0300 Subject: [PATCH] fix(eslint-plugin-query): detect rest destructuring on custom query hooks Adds an opportunistic type-aware path to no-rest-destructuring. When TypeScript parser services are available, the rule resolves the call expression's return type and reports rest destructuring on custom hooks that return a TanStack Query result. Untyped projects keep the existing AST-only behavior unchanged. Closes #8951 --- .../no-rest-destructuring-custom-hooks.md | 5 ++ docs/eslint/no-rest-destructuring.md | 2 + .../__tests__/no-rest-destructuring.test.ts | 78 +++++++++++++++++++ .../src/__tests__/ts-fixture/react-query.d.ts | 13 ++++ .../no-rest-destructuring.rule.ts | 24 ++++-- .../no-rest-destructuring.utils.ts | 46 ++++++++++- 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 .changeset/no-rest-destructuring-custom-hooks.md create mode 100644 packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts diff --git a/.changeset/no-rest-destructuring-custom-hooks.md b/.changeset/no-rest-destructuring-custom-hooks.md new file mode 100644 index 00000000000..c96fcc177d9 --- /dev/null +++ b/.changeset/no-rest-destructuring-custom-hooks.md @@ -0,0 +1,5 @@ +--- +"@tanstack/eslint-plugin-query": minor +--- + +`no-rest-destructuring` now also flags rest destructuring on custom hooks that return a TanStack Query result. Detection uses the TypeScript type checker and runs only when typed linting is enabled, so untyped projects are unaffected. Closes #8951. diff --git a/docs/eslint/no-rest-destructuring.md b/docs/eslint/no-rest-destructuring.md index 6b09218cc97..2eafc40e326 100644 --- a/docs/eslint/no-rest-destructuring.md +++ b/docs/eslint/no-rest-destructuring.md @@ -34,6 +34,8 @@ const todosQuery = useQuery({ const { data: todos } = todosQuery ``` +When [typed linting](https://typescript-eslint.io/getting-started/typed-linting/) is enabled, the rule also flags rest destructuring on custom hooks that return a TanStack Query result. + ## When Not To Use It If you set the `notifyOnChangeProps` options manually, you can disable this rule. diff --git a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts index fbe65f67709..c2050b78a57 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts @@ -1,7 +1,13 @@ +import path from 'node:path' import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' import { rule } from '../rules/no-rest-destructuring/no-rest-destructuring.rule' import { normalizeIndent } from './test-utils' +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + const ruleTester = new RuleTester() ruleTester.run('no-rest-destructuring', rule, { @@ -392,3 +398,75 @@ ruleTester.run('no-rest-destructuring', rule, { }, ], }) + +const ruleTesterTypeChecked = new RuleTester({ + languageOptions: { + parser: await import('@typescript-eslint/parser'), + parserOptions: { + project: true, + tsconfigRootDir: path.resolve(__dirname, './ts-fixture'), + }, + }, +}) + +ruleTesterTypeChecked.run('no-rest-destructuring with type information', rule, { + valid: [ + { + name: 'custom hook not returning a query result is destructured with rest', + code: normalizeIndent` + const useThing = () => ({ data: 1, isError: false }) + + function Component() { + const { data, ...rest } = useThing() + return null + } + `, + }, + { + name: 'custom hook returning a query result is destructured without rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const { data, isLoading } = useTodos() + return null + } + `, + }, + ], + invalid: [ + { + name: 'custom hook returning useQuery is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const { data, ...rest } = useTodos() + return null + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook result is spread in object expression', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => + useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) }) + + function Component() { + const todosQuery = useTodos() + return { ...todosQuery } + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts b/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts new file mode 100644 index 00000000000..370fc1bacda --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts @@ -0,0 +1,13 @@ +// Ambient stub so type-checked tests can resolve `@tanstack/react-query` +// without adding it as a devDependency of this plugin. +declare module '@tanstack/react-query' { + export type UseQueryResult = { + data: TData | undefined + isLoading: boolean + isError: boolean + } + export function useQuery(options: { + queryKey: ReadonlyArray + queryFn: () => Promise + }): UseQueryResult +} diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts index 42c116a702f..1c9e0f72f02 100644 --- a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts @@ -38,20 +38,30 @@ export const rule = createRule({ return { CallExpression: (node) => { + if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) { + return + } + + const isDirectHook = + ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) && + helpers.isTanstackQueryImport(node.callee) + if ( - !ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) || - node.parent.type !== AST_NODE_TYPES.VariableDeclarator || - !helpers.isTanstackQueryImport(node.callee) + !isDirectHook && + !NoRestDestructuringUtils.isQueryResultCall( + node, + context.sourceCode.parserServices, + ) ) { return } const returnValue = node.parent.id + const calleeName = ASTUtils.isIdentifier(node.callee) + ? node.callee.name + : null - if ( - node.callee.name !== 'useQueries' && - node.callee.name !== 'useSuspenseQueries' - ) { + if (calleeName !== 'useQueries' && calleeName !== 'useSuspenseQueries') { if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) { return context.report({ node: node.parent, diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts index 3cff8dcfa97..44133a2ca7e 100644 --- a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts @@ -1,5 +1,37 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils' -import type { TSESTree } from '@typescript-eslint/utils' +import type { + ParserServices, + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils' + +type TypeChecker = ReturnType< + ParserServicesWithTypeInformation['program']['getTypeChecker'] +> +type Type = ReturnType + +const QUERY_RESULT_TYPE_NAMES = new Set([ + 'UseBaseQueryResult', + 'UseQueryResult', + 'UseSuspenseQueryResult', + 'DefinedUseQueryResult', + 'UseInfiniteQueryResult', + 'UseSuspenseInfiniteQueryResult', + 'DefinedUseInfiniteQueryResult', + 'QueryObserverResult', + 'InfiniteQueryObserverResult', +]) + +function isQueryResultType(type: Type): boolean { + if (type.aliasSymbol && QUERY_RESULT_TYPE_NAMES.has(type.aliasSymbol.name)) { + return true + } + const symbol = type.getSymbol() + if (symbol && QUERY_RESULT_TYPE_NAMES.has(symbol.name)) { + return true + } + return type.isUnion() && type.types.some(isQueryResultType) +} export const NoRestDestructuringUtils = { isObjectRestDestructuring(node: TSESTree.Node): boolean { @@ -8,4 +40,16 @@ export const NoRestDestructuringUtils = { } return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement) }, + isQueryResultCall( + node: TSESTree.CallExpression, + parserServices: Partial | null | undefined, + ): boolean { + if (!parserServices?.program || !parserServices.esTreeNodeToTSNodeMap) { + return false + } + const checker = parserServices.program.getTypeChecker() + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.callee) + const signatures = checker.getTypeAtLocation(tsNode).getCallSignatures() + return signatures.some((sig) => isQueryResultType(sig.getReturnType())) + }, }