From af0933a160152dfc185093a9c2b7a85d947e0b25 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Sun, 8 Mar 2026 00:53:22 +0200 Subject: [PATCH 1/2] fix: improve exhaustive-deps to detect nested callback/control flow dependencies --- .changeset/perky-seas-flash.md | 5 + .../src/__tests__/exhaustive-deps.test.ts | 292 ++++++++++++++++++ .../exhaustive-deps/exhaustive-deps.rule.ts | 13 +- .../exhaustive-deps/exhaustive-deps.utils.ts | 149 +++++++++ .../src/utils/ast-utils.ts | 14 +- 5 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 .changeset/perky-seas-flash.md diff --git a/.changeset/perky-seas-flash.md b/.changeset/perky-seas-flash.md new file mode 100644 index 00000000000..bbe8c6d0bbc --- /dev/null +++ b/.changeset/perky-seas-flash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-query': patch +--- + +Fix `exhaustive-deps` to detect dependencies used inside nested `queryFn` callbacks/control flow, and avoid false positives when those dependencies are already present in complex `queryKey` expressions. diff --git a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts index 78a0a2fd6f4..938ccf5e3ec 100644 --- a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts @@ -259,6 +259,22 @@ ruleTester.run('exhaustive-deps', rule, { } `, }, + { + name: 'should pass when queryKey is a chained queryKeyFactory while having deps in nested calls', + code: normalizeIndent` + const fooQueryKeyFactory = { + foo: (num: number) => ({ + detail: (flag: boolean) => ['foo', num, flag] as const, + }), + } + + const useFoo = (num: number, flag: boolean) => + useQuery({ + queryKey: fooQueryKeyFactory.foo(num).detail(flag), + queryFn: () => Promise.resolve({ num, flag }), + }) + `, + }, { name: 'should not treat new Error as missing dependency', code: normalizeIndent` @@ -423,6 +439,50 @@ ruleTester.run('exhaustive-deps', rule, { } `, }, + { + name: 'should pass when queryKey uses a direct conditional expression', + code: normalizeIndent` + function Component(cond, a, b) { + useQuery({ + queryKey: ['thing', cond ? a : b], + queryFn: () => (cond ? a : b), + }) + } + `, + }, + { + name: 'should pass when queryKey uses a direct binary expression', + code: normalizeIndent` + function Component(a, b) { + useQuery({ + queryKey: ['thing', a + b], + queryFn: () => a + b, + }) + } + `, + }, + { + name: 'should pass when queryKey uses a nested type assertion', + code: normalizeIndent` + function Component(dep) { + useQuery({ + queryKey: ['thing', dep as string], + queryFn: () => dep, + }) + } + `, + }, + { + name: 'should pass when queryKey derives values inside a callback', + code: normalizeIndent` + function Component(ids, prefix) { + useQuery({ + queryKey: ['thing', ids.map((id) => prefix + '-' + id)], + queryFn: () => ({ ids, prefix }), + }) + } + `, + }, { name: 'instanceof value should not be in query key', code: ` @@ -587,6 +647,55 @@ ruleTester.run('exhaustive-deps', rule, { }) `, }, + { + name: 'should ignore callback locals in Vue file queryFn', + filename: 'Component.vue', + code: normalizeIndent` + import { useQuery } from '@tanstack/vue-query' + + const ids = [1, 2, 3] + useQuery({ + queryKey: ['entities', ids], + queryFn: () => ids.map((id) => fetchEntity(id)), + }) + `, + }, + { + name: 'should pass when dep used in then/catch is listed in queryKey', + code: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo', id], + queryFn: () => + Promise.resolve(null) + .then(() => id) + .catch(() => id), + }) + } + `, + }, + { + name: 'should pass when dep used in try/catch/finally is listed in queryKey', + code: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo', id], + queryFn: () => { + try { + return fetch(id) + } catch (error) { + console.error(error) + return id + } finally { + console.log('done') + } + }, + }) + } + `, + }, ], invalid: [ { @@ -888,6 +997,49 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when alias of props used in queryFn is missing in queryKey', + code: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff'], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'entities' }, + suggestions: [ + { + messageId: 'fixTo', + data: { result: "['get-stuff', entities]" }, + output: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff', entities], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + }, + ], + }, + ], + }, { name: 'should fail when queryKey is a queryKeyFactory while having missing dep', code: normalizeIndent` @@ -906,6 +1058,28 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when queryKey is a chained queryKeyFactory while having missing dep in earlier call', + code: normalizeIndent` + const fooQueryKeyFactory = { + foo: (num: number) => ({ + detail: (flag: boolean) => ['foo', num, flag] as const, + }), + } + + const useFoo = (num: number, flag: boolean) => + useQuery({ + queryKey: fooQueryKeyFactory.foo(1).detail(flag), + queryFn: () => Promise.resolve({ num, flag }), + }) + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'num' }, + }, + ], + }, { name: 'should fail if queryFn is using multiple object props when only one of them is in the queryKey', code: normalizeIndent` @@ -1084,5 +1258,123 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when dep used in then/catch is missing in queryKey', + code: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo'], + queryFn: () => + Promise.resolve(null) + .then(() => id) + .catch(() => id), + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'id' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo', id], + queryFn: () => + Promise.resolve(null) + .then(() => id) + .catch(() => id), + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when queryKey callback only references a shadowing local', + code: normalizeIndent` + function Component(id, ids) { + useQuery({ + queryKey: ['thing', ids.map((id) => id)], + queryFn: () => id, + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'id' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function Component(id, ids) { + useQuery({ + queryKey: ['thing', ids.map((id) => id), id], + queryFn: () => id, + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when dep used in try/catch/finally is missing in queryKey', + code: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo'], + queryFn: () => { + try { + return fetch(id) + } catch (error) { + console.error(error) + return id + } finally { + console.log('done') + } + }, + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'id' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function Component() { + const id = 1 + useQuery({ + queryKey: ['foo', id], + queryFn: () => { + try { + return fetch(id) + } catch (error) { + console.error(error) + return id + } finally { + console.log('done') + } + }, + }) + } + `, + }, + ], + }, + ], + }, ], }) diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts index c3a12e5e18a..0b63cce492a 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts @@ -86,10 +86,11 @@ export const rule = createRule({ }), ) - const existingKeys = ASTUtils.getNestedIdentifiers(queryKeyNode).map( - (identifier) => - ASTUtils.mapKeyNodeToBaseText(identifier, context.sourceCode), - ) + const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({ + sourceCode: context.sourceCode, + scopeManager, + queryKeyNode, + }) const missingRefs = relevantRefs .map((ref) => ({ @@ -103,8 +104,8 @@ export const rule = createRule({ return ( !ref.isTypeReference && !ASTUtils.isAncestorIsCallee(ref.identifier) && - !existingKeys.some((existingKey) => existingKey === text) && - !existingKeys.includes(text.split(/[?.]/)[0] ?? '') + !queryKeyDeps.has(text) && + !queryKeyDeps.has(text.split(/[?.]/)[0] ?? '') ) }) .map(({ ref, text }) => ({ diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts index c0533e39101..1bf521a3a32 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts @@ -12,6 +12,20 @@ export const ExhaustiveDepsUtils = { }) { const { sourceCode, reference, scopeManager, node, filename } = params const component = ASTUtils.getFunctionAncestor(sourceCode, node) + const queryFnScope = scopeManager.acquire(node) + + if (queryFnScope === null) { + return false + } + + let currentScope = reference.resolved?.scope ?? null + while (currentScope !== null) { + if (currentScope === queryFnScope) { + return false + } + + currentScope = currentScope.upper + } if (component !== undefined) { if ( @@ -51,4 +65,139 @@ export const ExhaustiveDepsUtils = { node.operator === 'instanceof' ) }, + + collectQueryKeyDeps(params: { + sourceCode: Readonly + scopeManager: TSESLint.Scope.ScopeManager + queryKeyNode: TSESTree.Node + }): Set { + const { sourceCode, scopeManager, queryKeyNode } = params + const deps = new Set() + const visitorKeys = sourceCode.visitorKeys + + function add(identifier: TSESTree.Identifier) { + deps.add(ASTUtils.mapKeyNodeToBaseText(identifier, sourceCode)) + } + + function visitChildren(node: TSESTree.Node): void { + for (const key of visitorKeys[node.type] ?? []) { + const value = (node as Record)[key] + + if (Array.isArray(value)) { + for (const item of value) { + if (ExhaustiveDepsUtils.isNode(item)) { + visit(item) + } + } + continue + } + + if (ExhaustiveDepsUtils.isNode(value)) { + visit(value) + } + } + } + + function visit(node: TSESTree.Node | null | undefined): void { + if (!node) return + + switch (node.type) { + case AST_NODE_TYPES.Identifier: + add(node) + return + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.FunctionExpression: + for (const reference of ExhaustiveDepsUtils.collectExternalRefsInFunction( + { + functionNode: node, + scopeManager: scopeManager, + }, + )) { + if (reference.identifier.type === AST_NODE_TYPES.Identifier) { + add(reference.identifier) + } + } + return + case AST_NODE_TYPES.Property: + visit(node.value) + return + case AST_NODE_TYPES.MemberExpression: + visit(node.object) + return + case AST_NODE_TYPES.CallExpression: + node.arguments.forEach((argument) => visit(argument)) + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression || + node.callee.type === AST_NODE_TYPES.ChainExpression || + node.callee.type === AST_NODE_TYPES.TSNonNullExpression + ) { + visit(node.callee) + } + return + } + + visitChildren(node) + } + + visit(queryKeyNode) + + return deps + }, + + isNode(value: unknown): value is TSESTree.Node { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof value.type === 'string' + ) + }, + + collectExternalRefsInFunction(params: { + functionNode: + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression + scopeManager: TSESLint.Scope.ScopeManager + }): Array { + const { functionNode, scopeManager } = params + const functionScope = scopeManager.acquire(functionNode) + + if (functionScope === null) { + return [] + } + + const externalRefs: Array = [] + + function collect(scope: TSESLint.Scope.Scope) { + for (const reference of scope.references) { + if (!reference.isRead() || reference.resolved === null) { + continue + } + + let currentScope: TSESLint.Scope.Scope | null = reference.resolved.scope + let declaredInsideFunction = false + + while (currentScope !== null) { + if (currentScope === functionScope) { + declaredInsideFunction = true + break + } + + currentScope = currentScope.upper + } + + if (!declaredInsideFunction) { + externalRefs.push(reference) + } + } + + for (const childScope of scope.childScopes) { + collect(childScope) + } + } + + collect(functionScope) + + return externalRefs + }, } diff --git a/packages/eslint-plugin-query/src/utils/ast-utils.ts b/packages/eslint-plugin-query/src/utils/ast-utils.ts index 10fdedc9875..28c9b6f7421 100644 --- a/packages/eslint-plugin-query/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-query/src/utils/ast-utils.ts @@ -192,7 +192,19 @@ export const ASTUtils = { return [] } - const references = scope.references + const collectReferences = ( + currentScope: TSESLint.Scope.Scope, + ): Array => { + const references = [...currentScope.references] + + for (const childScope of currentScope.childScopes) { + references.push(...collectReferences(childScope)) + } + + return references + } + + const references = collectReferences(scope) .filter((x) => x.isRead() && !scope.set.has(x.identifier.name)) .map((x) => { const referenceNode = ASTUtils.traverseUpOnly(x.identifier, [ From f643d7282082d6d0fbbac4ca081f259555c5b209 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Sun, 8 Mar 2026 11:25:22 +0200 Subject: [PATCH 2/2] fix: detect callee objects as dependencies in exhaustive-deps rule --- .../src/__tests__/exhaustive-deps.test.ts | 51 ++++++++++++++----- .../exhaustive-deps/exhaustive-deps.rule.ts | 15 +++--- .../exhaustive-deps/exhaustive-deps.utils.ts | 22 +++++--- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts index 938ccf5e3ec..d16ee89d54d 100644 --- a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts @@ -34,20 +34,6 @@ ruleTester.run('exhaustive-deps', rule, { name: 'should not pass api.entity.get', code: 'useQuery({ queryKey: ["entity", id], queryFn: () => api.entity.get(id) });', }, - { - name: 'should not pass api when is being used for calling a function', - code: ` - import useApi from './useApi' - - const useFoo = () => { - const api = useApi(); - return useQuery({ - queryKey: ['foo'], - queryFn: () => api.fetchFoo(), - }) - } - `, - }, { name: 'should pass props.src', code: ` @@ -698,6 +684,43 @@ ruleTester.run('exhaustive-deps', rule, { }, ], invalid: [ + { + name: 'should fail when api from hook is used for calling a function', + code: normalizeIndent` + import useApi from './useApi' + + const useFoo = () => { + const api = useApi(); + return useQuery({ + queryKey: ['foo'], + queryFn: () => api.fetchFoo(), + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'api' }, + suggestions: [ + { + messageId: 'fixTo', + data: { result: "['foo', api]" }, + output: normalizeIndent` + import useApi from './useApi' + + const useFoo = () => { + const api = useApi(); + return useQuery({ + queryKey: ['foo', api], + queryFn: () => api.fetchFoo(), + }) + } + `, + }, + ], + }, + ], + }, { name: 'should fail when deps are missing in query factory', code: normalizeIndent` diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts index 0b63cce492a..2f20877c67e 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts @@ -95,15 +95,16 @@ export const rule = createRule({ const missingRefs = relevantRefs .map((ref) => ({ ref: ref, - text: ASTUtils.mapKeyNodeToBaseText( - ref.identifier, - context.sourceCode, - ), + text: ASTUtils.isAncestorIsCallee(ref.identifier) + ? ref.identifier.name + : ASTUtils.mapKeyNodeToBaseText( + ref.identifier, + context.sourceCode, + ), })) .filter(({ ref, text }) => { return ( !ref.isTypeReference && - !ASTUtils.isAncestorIsCallee(ref.identifier) && !queryKeyDeps.has(text) && !queryKeyDeps.has(text.split(/[?.]/)[0] ?? '') ) @@ -117,9 +118,7 @@ export const rule = createRule({ if (uniqueMissingRefs.length > 0) { const missingAsText = uniqueMissingRefs - .map((ref) => - ASTUtils.mapKeyNodeToText(ref.identifier, context.sourceCode), - ) + .map((ref) => ref.text) .join(', ') const queryKeyValue = context.sourceCode.getText(queryKeyNode) diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts index 1bf521a3a32..32167e92199 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts @@ -80,8 +80,12 @@ export const ExhaustiveDepsUtils = { } function visitChildren(node: TSESTree.Node): void { - for (const key of visitorKeys[node.type] ?? []) { - const value = (node as Record)[key] + const keys = (visitorKeys[node.type] ?? []) as ReadonlyArray< + keyof TSESTree.Node + > + + for (const key of keys) { + const value = node[key] if (Array.isArray(value)) { for (const item of value) { @@ -122,7 +126,15 @@ export const ExhaustiveDepsUtils = { visit(node.value) return case AST_NODE_TYPES.MemberExpression: - visit(node.object) + if ( + node.parent.type === AST_NODE_TYPES.CallExpression && + node.parent.callee === node && + node.object.type === AST_NODE_TYPES.Identifier + ) { + deps.add(node.object.name) + } else { + visit(node.object) + } return case AST_NODE_TYPES.CallExpression: node.arguments.forEach((argument) => visit(argument)) @@ -154,9 +166,7 @@ export const ExhaustiveDepsUtils = { }, collectExternalRefsInFunction(params: { - functionNode: - | TSESTree.ArrowFunctionExpression - | TSESTree.FunctionExpression + functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression scopeManager: TSESLint.Scope.ScopeManager }): Array { const { functionNode, scopeManager } = params