Skip to content

Commit fc06643

Browse files
committed
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
1 parent ba6e7be commit fc06643

6 files changed

Lines changed: 160 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/eslint-plugin-query": minor
3+
---
4+
5+
`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.

docs/eslint/no-rest-destructuring.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const todosQuery = useQuery({
3434
const { data: todos } = todosQuery
3535
```
3636

37+
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.
38+
3739
## When Not To Use It
3840

3941
If you set the `notifyOnChangeProps` options manually, you can disable this rule.

packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import path from 'node:path'
12
import { RuleTester } from '@typescript-eslint/rule-tester'
3+
import { afterAll, describe, it } from 'vitest'
24
import { rule } from '../rules/no-rest-destructuring/no-rest-destructuring.rule'
35
import { normalizeIndent } from './test-utils'
46

7+
RuleTester.afterAll = afterAll
8+
RuleTester.describe = describe
9+
RuleTester.it = it
10+
511
const ruleTester = new RuleTester()
612

713
ruleTester.run('no-rest-destructuring', rule, {
@@ -392,3 +398,75 @@ ruleTester.run('no-rest-destructuring', rule, {
392398
},
393399
],
394400
})
401+
402+
const ruleTesterTypeChecked = new RuleTester({
403+
languageOptions: {
404+
parser: await import('@typescript-eslint/parser'),
405+
parserOptions: {
406+
project: true,
407+
tsconfigRootDir: path.resolve(__dirname, './ts-fixture'),
408+
},
409+
},
410+
})
411+
412+
ruleTesterTypeChecked.run('no-rest-destructuring with type information', rule, {
413+
valid: [
414+
{
415+
name: 'custom hook not returning a query result is destructured with rest',
416+
code: normalizeIndent`
417+
const useThing = () => ({ data: 1, isError: false })
418+
419+
function Component() {
420+
const { data, ...rest } = useThing()
421+
return null
422+
}
423+
`,
424+
},
425+
{
426+
name: 'custom hook returning a query result is destructured without rest',
427+
code: normalizeIndent`
428+
import { useQuery } from '@tanstack/react-query'
429+
430+
const useTodos = () =>
431+
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
432+
433+
function Component() {
434+
const { data, isLoading } = useTodos()
435+
return null
436+
}
437+
`,
438+
},
439+
],
440+
invalid: [
441+
{
442+
name: 'custom hook returning useQuery is destructured with rest',
443+
code: normalizeIndent`
444+
import { useQuery } from '@tanstack/react-query'
445+
446+
const useTodos = () =>
447+
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
448+
449+
function Component() {
450+
const { data, ...rest } = useTodos()
451+
return null
452+
}
453+
`,
454+
errors: [{ messageId: 'objectRestDestructure' }],
455+
},
456+
{
457+
name: 'custom hook result is spread in object expression',
458+
code: normalizeIndent`
459+
import { useQuery } from '@tanstack/react-query'
460+
461+
const useTodos = () =>
462+
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
463+
464+
function Component() {
465+
const todosQuery = useTodos()
466+
return { ...todosQuery }
467+
}
468+
`,
469+
errors: [{ messageId: 'objectRestDestructure' }],
470+
},
471+
],
472+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Ambient stub so type-checked tests can resolve `@tanstack/react-query`
2+
// without adding it as a devDependency of this plugin.
3+
declare module '@tanstack/react-query' {
4+
export type UseQueryResult<TData = unknown> = {
5+
data: TData | undefined
6+
isLoading: boolean
7+
isError: boolean
8+
}
9+
export function useQuery<TData>(options: {
10+
queryKey: ReadonlyArray<unknown>
11+
queryFn: () => Promise<TData>
12+
}): UseQueryResult<TData>
13+
}

packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,30 @@ export const rule = createRule({
3838

3939
return {
4040
CallExpression: (node) => {
41+
if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) {
42+
return
43+
}
44+
45+
const isDirectHook =
46+
ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) &&
47+
helpers.isTanstackQueryImport(node.callee)
48+
4149
if (
42-
!ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) ||
43-
node.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
44-
!helpers.isTanstackQueryImport(node.callee)
50+
!isDirectHook &&
51+
!NoRestDestructuringUtils.isQueryResultCall(
52+
node,
53+
context.sourceCode.parserServices,
54+
)
4555
) {
4656
return
4757
}
4858

4959
const returnValue = node.parent.id
60+
const calleeName = ASTUtils.isIdentifier(node.callee)
61+
? node.callee.name
62+
: null
5063

51-
if (
52-
node.callee.name !== 'useQueries' &&
53-
node.callee.name !== 'useSuspenseQueries'
54-
) {
64+
if (calleeName !== 'useQueries' && calleeName !== 'useSuspenseQueries') {
5565
if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
5666
return context.report({
5767
node: node.parent,
Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
2-
import type { TSESTree } from '@typescript-eslint/utils'
2+
import type {
3+
ParserServices,
4+
ParserServicesWithTypeInformation,
5+
TSESTree,
6+
} from '@typescript-eslint/utils'
7+
8+
type TypeChecker = ReturnType<
9+
ParserServicesWithTypeInformation['program']['getTypeChecker']
10+
>
11+
type Type = ReturnType<TypeChecker['getTypeAtLocation']>
12+
13+
const QUERY_RESULT_TYPE_NAMES = new Set([
14+
'UseBaseQueryResult',
15+
'UseQueryResult',
16+
'UseSuspenseQueryResult',
17+
'DefinedUseQueryResult',
18+
'UseInfiniteQueryResult',
19+
'UseSuspenseInfiniteQueryResult',
20+
'DefinedUseInfiniteQueryResult',
21+
'QueryObserverResult',
22+
'InfiniteQueryObserverResult',
23+
])
24+
25+
function isQueryResultType(type: Type): boolean {
26+
if (type.aliasSymbol && QUERY_RESULT_TYPE_NAMES.has(type.aliasSymbol.name)) {
27+
return true
28+
}
29+
const symbol = type.getSymbol()
30+
if (symbol && QUERY_RESULT_TYPE_NAMES.has(symbol.name)) {
31+
return true
32+
}
33+
return type.isUnion() && type.types.some(isQueryResultType)
34+
}
335

436
export const NoRestDestructuringUtils = {
537
isObjectRestDestructuring(node: TSESTree.Node): boolean {
@@ -8,4 +40,16 @@ export const NoRestDestructuringUtils = {
840
}
941
return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement)
1042
},
43+
isQueryResultCall(
44+
node: TSESTree.CallExpression,
45+
parserServices: Partial<ParserServices> | null | undefined,
46+
): boolean {
47+
if (!parserServices?.program || !parserServices.esTreeNodeToTSNodeMap) {
48+
return false
49+
}
50+
const checker = parserServices.program.getTypeChecker()
51+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.callee)
52+
const signatures = checker.getTypeAtLocation(tsNode).getCallSignatures()
53+
return signatures.some((sig) => isQueryResultType(sig.getReturnType()))
54+
},
1155
}

0 commit comments

Comments
 (0)