Skip to content
Merged
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-query-next-experimental': patch
---

fix(react-query-next-experimental): replace deprecated 'isServer' with 'environmentManager.isServer()'
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
4 changes: 2 additions & 2 deletions examples/react/nextjs-app-prefetching/app/get-query-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
environmentManager,
} from '@tanstack/react-query'

function makeQueryClient() {
Expand All @@ -23,7 +23,7 @@ function makeQueryClient() {
let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
if (isServer) {
if (environmentManager.isServer()) {
// Server: always make a new query client
return makeQueryClient()
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import {
QueryClient,
QueryClientProvider,
isServer,
environmentManager,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import * as React from 'react'
Expand All @@ -22,7 +22,7 @@ function makeQueryClient() {
let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
if (isServer) {
if (environmentManager.isServer()) {
return makeQueryClient()
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient()
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,19 +38,42 @@ 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

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 (
node.callee.name !== 'useQueries' &&
node.callee.name !== 'useSuspenseQueries'
calleeName !== 'useQueries' &&
calleeName !== 'useSuspenseQueries'
) {
if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
return context.report({
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([
'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 {
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()))
},
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { isServer } from '@tanstack/react-query'
import { environmentManager } from '@tanstack/react-query'
import { useServerInsertedHTML } from 'next/navigation'
import * as React from 'react'
import { htmlEscapeJsonString } from './htmlescape'
Expand Down Expand Up @@ -106,7 +106,7 @@ export function createHydrationStreamProvider<TShape>() {

// <server stuff>
const [stream] = React.useState<Array<TShape>>(() => {
if (!isServer) {
if (!environmentManager.isServer()) {
return {
push() {
// no-op on the client
Expand Down Expand Up @@ -154,7 +154,7 @@ export function createHydrationStreamProvider<TShape>() {
// the initial render so children have access to the data immediately
// This is important to avoid the client suspending during the initial render
// if the data has not yet been hydrated.
if (!isServer) {
if (!environmentManager.isServer()) {
const win = window as any
if (!win[id]?.initialized) {
// Client: consume cache:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import {
defaultShouldDehydrateQuery,
dehydrate,
environmentManager,
hydrate,
isServer,
useQueryClient,
} from '@tanstack/react-query'
import * as React from 'react'
Expand Down Expand Up @@ -42,7 +42,7 @@ export function ReactQueryStreamedHydration(props: {
const [trackedKeys] = React.useState(() => new Set<string>())

// <server only>
if (isServer) {
if (environmentManager.isServer()) {
// Do we need to care about unsubscribing? I don't think so to be honest
queryClient.getQueryCache().subscribe((event) => {
switch (event.type) {
Expand Down
Loading