Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
},
}
Loading