Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/no-rest-destructuring-custom-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/eslint/no-rest-destructuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down Expand Up @@ -392,3 +398,109 @@ 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' }],
},
{
name: 'custom hook result is assigned then destructured with rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'

const useTodos = () =>
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })

function Component() {
const todosQuery = useTodos()
const { data, ...rest } = todosQuery
return null
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'custom hook returning an interface query result is destructured with rest',
code: normalizeIndent`
import type { QueryObserverResult } from '@tanstack/react-query'

const useTodos = (): QueryObserverResult => ({
data: undefined,
isLoading: false,
isError: false,
})

function Component() {
const { data, ...rest } = useTodos()
return null
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
],
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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<TData = unknown> = {
data: TData | undefined
isLoading: boolean
isError: boolean
}
// Declared as an interface so its type resolves via `getSymbol()` rather
// than `aliasSymbol`, exercising the non-alias detection path.
export interface QueryObserverResult<TData = unknown> {
data: TData | undefined
isLoading: boolean
isError: boolean
}
export function useQuery<TData>(options: {
queryKey: ReadonlyArray<unknown>
queryFn: () => Promise<TData>
}): UseQueryResult<TData>
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,40 @@ export const rule = createRule({

return {
CallExpression: (node) => {
if (
!ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) ||
node.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
!helpers.isTanstackQueryImport(node.callee)
) {
if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) {
return
}

const returnValue = node.parent.id

if (
node.callee.name !== 'useQueries' &&
node.callee.name !== 'useSuspenseQueries'
) {
const isDirectHook =
ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) &&
helpers.isTanstackQueryImport(node.callee)

if (!isDirectHook) {
// The type-aware path can only report when the result is rest
// destructured or assigned to an identifier that may later be
// spread. Skip the expensive type lookup for any other binding.
const canReportQueryResult =
returnValue.type === AST_NODE_TYPES.Identifier ||
NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)

if (
!canReportQueryResult ||
!NoRestDestructuringUtils.isQueryResultCall(
node,
context.sourceCode.parserServices,
)
) {
return
}
}

const calleeName = ASTUtils.isIdentifier(node.callee)
? node.callee.name
: null

if (calleeName !== 'useQueries' && calleeName !== 'useSuspenseQueries') {
if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
return context.report({
node: node.parent,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TypeChecker['getTypeAtLocation']>

const QUERY_RESULT_TYPE_NAMES = new Set([
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this arbitrary list. I'm open to suggestions

'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)
Comment thread
Newbie012 marked this conversation as resolved.
}

export const NoRestDestructuringUtils = {
isObjectRestDestructuring(node: TSESTree.Node): boolean {
Expand All @@ -8,4 +40,16 @@ export const NoRestDestructuringUtils = {
}
return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement)
},
isQueryResultCall(
node: TSESTree.CallExpression,
parserServices: Partial<ParserServices> | 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()))
Comment thread
Newbie012 marked this conversation as resolved.
},
}