diff --git a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts index fbe65f67709..d1ef618e28e 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts @@ -285,6 +285,19 @@ ruleTester.run('no-rest-destructuring', rule, { } `, }, + { + name: 'custom hook returning non-tanstack useQuery is destructured with rest', + code: normalizeIndent` + import { useQuery } from 'other-package' + + const useTodos = () => useQuery() + + function Component() { + const { data, ...rest } = useTodos() + return + } + `, + }, ], invalid: [ { @@ -390,5 +403,87 @@ ruleTester.run('no-rest-destructuring', rule, { `, errors: [{ messageId: 'objectRestDestructure' }], }, + { + name: 'custom hook that returns useQuery is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function useTodos() { + return useQuery() + } + + function Component() { + const { data, ...rest } = useTodos() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook with arrow function is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => useQuery() + + function Component() { + const { data, ...rest } = useTodos() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook with arrow function block is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => { + return useQuery({ + queryKey: ['todos'], + queryFn: () => fetch('example.com/todos'), + }) + } + + function Component() { + const { data, ...rest } = useTodos() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook returning query result variable is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const useTodos = () => { + const query = useQuery() + return query + } + + function Component() { + const { data, ...rest } = useTodos() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'custom hook declared after component is destructured with rest', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const { data, ...rest } = useTodos() + return + } + + function useTodos() { + return useQuery() + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, ], }) diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts index 42c116a702f..e621a3c323a 100644 --- a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts @@ -3,6 +3,7 @@ import { getDocsUrl } from '../../utils/get-docs-url' import { ASTUtils } from '../../utils/ast-utils' import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' import { NoRestDestructuringUtils } from './no-rest-destructuring.utils' +import type { TSESTree } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from '../../types' export const name = 'no-rest-destructuring' @@ -36,16 +37,176 @@ export const rule = createRule({ create: detectTanstackQueryImports((context, _, helpers) => { const queryResultVariables = new Set() + const isTanstackQueryHook = (identifier: TSESTree.Identifier): boolean => { + return ( + ASTUtils.isIdentifierWithOneOfNames(identifier, queryHooks) && + helpers.isTanstackQueryImport(identifier) + ) + } + + const unwrap = (node: TSESTree.Node): TSESTree.Node => { + if ( + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSSatisfiesExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion || + node.type === AST_NODE_TYPES.ChainExpression || + node.type === AST_NODE_TYPES.TSNonNullExpression + ) { + return unwrap(node.expression) + } + + return node + } + + const getReferencedNode = ( + identifier: TSESTree.Identifier, + ): TSESTree.Node | null => { + const referencedExpression = ASTUtils.getReferencedExpressionByIdentifier( + { + context, + node: identifier, + }, + ) + + if (referencedExpression !== null) { + return referencedExpression + } + + const scope = context.sourceCode.getScope(identifier) + const reference = scope.references.find( + (ref) => ref.identifier === identifier, + ) + const definition = reference?.resolved?.defs[0]?.node as + | TSESTree.Node + | undefined + + if (definition?.type === AST_NODE_TYPES.VariableDeclarator) { + return definition.init ?? null + } + + if (definition !== undefined) { + return definition + } + + return null + } + + const getDirectReturnExpression = ( + fn: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression, + ): TSESTree.Expression | null => { + if (fn.body.type !== AST_NODE_TYPES.BlockStatement) { + return fn.body + } + + const returnStatements = fn.body.body.filter( + (statement): statement is TSESTree.ReturnStatement => + statement.type === AST_NODE_TYPES.ReturnStatement, + ) + + if (returnStatements.length !== 1) { + return null + } + + const [returnStatement] = returnStatements + + return returnStatement?.argument ?? null + } + + const isQueryResultNode = ( + node: TSESTree.Node | null, + seen: Set, + ): boolean => { + if (node === null) { + return false + } + + const unwrapped = unwrap(node) + + if (unwrapped.type === AST_NODE_TYPES.Identifier) { + return isQueryResultIdentifier(unwrapped, seen) + } + + if (unwrapped.type !== AST_NODE_TYPES.CallExpression) { + return false + } + + if (unwrapped.callee.type !== AST_NODE_TYPES.Identifier) { + return false + } + + if (isTanstackQueryHook(unwrapped.callee)) { + return true + } + + return isQueryResultIdentifier(unwrapped.callee, seen) + } + + const isQueryResultIdentifier = ( + node: TSESTree.Identifier, + seen: Set, + ): boolean => { + if (isTanstackQueryHook(node)) { + return true + } + + if (seen.has(node.name)) { + return false + } + + seen.add(node.name) + + const referenced = getReferencedNode(node) + if (referenced === null || referenced === node) { + return false + } + + const unwrapped = unwrap(referenced) + + if ( + unwrapped.type === AST_NODE_TYPES.FunctionDeclaration || + unwrapped.type === AST_NODE_TYPES.FunctionExpression || + unwrapped.type === AST_NODE_TYPES.ArrowFunctionExpression + ) { + const returned = getDirectReturnExpression(unwrapped) + return isQueryResultNode(returned, seen) + } + + return isQueryResultNode(unwrapped, seen) + } + + const isQueryResultHookCall = (node: TSESTree.CallExpression): boolean => { + return isQueryResultNode(node, new Set()) + } + return { + VariableDeclarator: (node) => { + if ( + node.init?.type === AST_NODE_TYPES.Identifier && + queryResultVariables.has(node.init.name) && + NoRestDestructuringUtils.isObjectRestDestructuring(node.id) + ) { + context.report({ + node, + messageId: 'objectRestDestructure', + }) + } + }, + CallExpression: (node) => { if ( - !ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) || - node.parent.type !== AST_NODE_TYPES.VariableDeclarator || - !helpers.isTanstackQueryImport(node.callee) + node.callee.type !== AST_NODE_TYPES.Identifier || + !isQueryResultHookCall(node) ) { return } + if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) { + return + } + const returnValue = node.parent.id if ( @@ -77,6 +238,7 @@ export const rule = createRule({ if (queryResult === null) { return } + if (NoRestDestructuringUtils.isObjectRestDestructuring(queryResult)) { context.report({ node: queryResult, @@ -86,19 +248,6 @@ export const rule = createRule({ }) }, - VariableDeclarator: (node) => { - if ( - node.init?.type === AST_NODE_TYPES.Identifier && - queryResultVariables.has(node.init.name) && - NoRestDestructuringUtils.isObjectRestDestructuring(node.id) - ) { - context.report({ - node, - messageId: 'objectRestDestructure', - }) - } - }, - SpreadElement: (node) => { if ( node.argument.type === AST_NODE_TYPES.Identifier &&