Skip to content

Commit dc54932

Browse files
Newbie012TkDodoautofix-ci[bot]
authored
fix(eslint-plugin-query): detect rest destructuring on custom query hooks (#10775)
* 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 * perf(eslint-plugin-query): gate type lookup behind cheap binding check Only run the type checker on non-direct hook calls when the binding can actually report (rest destructure or identifier), avoiding type lookups on every variable declarator. Add tests for cross-statement rest destructuring and interface-typed (non-alias) query results. * ci: apply automated fixes --------- Co-authored-by: Dominik Dorfmeister 🔮 <office@dorfmeister.cc> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 7cf5923 commit dc54932

6 files changed

Lines changed: 214 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: 112 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,109 @@ 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+
name: 'custom hook result is assigned then destructured with rest',
473+
code: normalizeIndent`
474+
import { useQuery } from '@tanstack/react-query'
475+
476+
const useTodos = () =>
477+
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
478+
479+
function Component() {
480+
const todosQuery = useTodos()
481+
const { data, ...rest } = todosQuery
482+
return null
483+
}
484+
`,
485+
errors: [{ messageId: 'objectRestDestructure' }],
486+
},
487+
{
488+
name: 'custom hook returning an interface query result is destructured with rest',
489+
code: normalizeIndent`
490+
import type { QueryObserverResult } from '@tanstack/react-query'
491+
492+
const useTodos = (): QueryObserverResult => ({
493+
data: undefined,
494+
isLoading: false,
495+
isError: false,
496+
})
497+
498+
function Component() {
499+
const { data, ...rest } = useTodos()
500+
return null
501+
}
502+
`,
503+
errors: [{ messageId: 'objectRestDestructure' }],
504+
},
505+
],
506+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
// Declared as an interface so its type resolves via `getSymbol()` rather
10+
// than `aliasSymbol`, exercising the non-alias detection path.
11+
export interface QueryObserverResult<TData = unknown> {
12+
data: TData | undefined
13+
isLoading: boolean
14+
isError: boolean
15+
}
16+
export function useQuery<TData>(options: {
17+
queryKey: ReadonlyArray<unknown>
18+
queryFn: () => Promise<TData>
19+
}): UseQueryResult<TData>
20+
}

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

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

3939
return {
4040
CallExpression: (node) => {
41-
if (
42-
!ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) ||
43-
node.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
44-
!helpers.isTanstackQueryImport(node.callee)
45-
) {
41+
if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) {
4642
return
4743
}
4844

4945
const returnValue = node.parent.id
5046

47+
const isDirectHook =
48+
ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) &&
49+
helpers.isTanstackQueryImport(node.callee)
50+
51+
if (!isDirectHook) {
52+
// The type-aware path can only report when the result is rest
53+
// destructured or assigned to an identifier that may later be
54+
// spread. Skip the expensive type lookup for any other binding.
55+
const canReportQueryResult =
56+
returnValue.type === AST_NODE_TYPES.Identifier ||
57+
NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)
58+
59+
if (
60+
!canReportQueryResult ||
61+
!NoRestDestructuringUtils.isQueryResultCall(
62+
node,
63+
context.sourceCode.parserServices,
64+
)
65+
) {
66+
return
67+
}
68+
}
69+
70+
const calleeName = ASTUtils.isIdentifier(node.callee)
71+
? node.callee.name
72+
: null
73+
5174
if (
52-
node.callee.name !== 'useQueries' &&
53-
node.callee.name !== 'useSuspenseQueries'
75+
calleeName !== 'useQueries' &&
76+
calleeName !== 'useSuspenseQueries'
5477
) {
5578
if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
5679
return context.report({
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)