From 16050b3ae0d90c556c084fda035fa000c19fc413 Mon Sep 17 00:00:00 2001 From: junjuny0227 Date: Tue, 19 May 2026 10:27:42 +0900 Subject: [PATCH 1/3] fix(eslint-plugin-query): track custom query hook wrappers --- .../src/__tests__/no-unstable-deps.test.ts | 44 +++++++ .../no-unstable-deps/no-unstable-deps.rule.ts | 110 ++++++++++++++++-- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts index b111fc0ed73..3650afb6ba4 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts @@ -163,6 +163,50 @@ const baseTestCases = { }, ], }, + { + name: `result of custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useMutation } from "@tanstack/react-query"; + + const useMyMutation = () => useMutation({ mutationFn: (value: string) => value }); + + function Component() { + const mutation = useMyMutation(); + const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useMutation' }, + }, + ], + }, + { + name: `result of custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQuery } from "@tanstack/react-query"; + + function useMyQuery() { + return useQuery({ queryFn: (value: string) => value }); + } + + function Component() { + const query = useMyQuery(); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQuery' }, + }, + ], + }, ]), } diff --git a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts index 32e68464682..590a6a2bd7c 100644 --- a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts @@ -36,6 +36,7 @@ export const rule = createRule({ create: detectTanstackQueryImports((context, _options, helpers) => { const trackedVariables: Record = {} + const trackedCustomHooks: Record = {} const hookAliasMap: Record = {} function getReactHook(node: TSESTree.CallExpression): string | undefined { @@ -67,6 +68,10 @@ export const rule = createRule({ } } + function isCustomHookName(hookName: string): boolean { + return /^use[A-Z0-9]/.test(hookName) + } + function hasCombineProperty( callExpression: TSESTree.CallExpression, ): boolean { @@ -84,6 +89,71 @@ export const rule = createRule({ ) } + function getDirectQueryHook( + callExpression: TSESTree.CallExpression, + ): string | undefined { + if ( + callExpression.callee.type !== AST_NODE_TYPES.Identifier || + !allHookNames.includes(callExpression.callee.name) || + !helpers.isTanstackQueryImport(callExpression.callee) + ) { + return undefined + } + + if ( + callExpression.callee.name === 'useQueries' && + hasCombineProperty(callExpression) + ) { + return undefined + } + + return callExpression.callee.name + } + + function getTrackedQueryHook( + callExpression: TSESTree.CallExpression, + ): string | undefined { + const directQueryHook = getDirectQueryHook(callExpression) + if (directQueryHook !== undefined) { + return directQueryHook + } + + if (callExpression.callee.type === AST_NODE_TYPES.Identifier) { + return trackedCustomHooks[callExpression.callee.name] + } + + return undefined + } + + function getReturnedQueryHook( + body: + | TSESTree.FunctionExpression['body'] + | TSESTree.ArrowFunctionExpression['body'], + ): string | undefined { + if (body.type === AST_NODE_TYPES.CallExpression) { + return getDirectQueryHook(body) + } + + if (body.type !== AST_NODE_TYPES.BlockStatement) { + return undefined + } + + const returnStatements = body.body.filter( + (statement): statement is TSESTree.ReturnStatement => + statement.type === AST_NODE_TYPES.ReturnStatement, + ) + if (returnStatements.length !== 1) { + return undefined + } + + const returnArgument = returnStatements[0]?.argument + if (returnArgument?.type === AST_NODE_TYPES.CallExpression) { + return getDirectQueryHook(returnArgument) + } + + return undefined + } + return { ImportDeclaration(node: TSESTree.ImportDeclaration) { if ( @@ -104,23 +174,39 @@ export const rule = createRule({ } }, + FunctionDeclaration(node) { + if (node.id === null || !isCustomHookName(node.id.name)) { + return + } + + const queryHook = getReturnedQueryHook(node.body) + if (queryHook !== undefined) { + trackedCustomHooks[node.id.name] = queryHook + } + }, + VariableDeclarator(node) { + if ( + node.id.type === AST_NODE_TYPES.Identifier && + isCustomHookName(node.id.name) && + node.init !== null && + (node.init.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.init.type === AST_NODE_TYPES.FunctionExpression) + ) { + const queryHook = getReturnedQueryHook(node.init.body) + if (queryHook !== undefined) { + trackedCustomHooks[node.id.name] = queryHook + } + } + if ( node.init !== null && - node.init.type === AST_NODE_TYPES.CallExpression && - node.init.callee.type === AST_NODE_TYPES.Identifier && - allHookNames.includes(node.init.callee.name) && - helpers.isTanstackQueryImport(node.init.callee) + node.init.type === AST_NODE_TYPES.CallExpression ) { - // Special case for useQueries with combine property - it's stable - if ( - node.init.callee.name === 'useQueries' && - hasCombineProperty(node.init) - ) { - // Don't track useQueries with combine as unstable - return + const queryHook = getTrackedQueryHook(node.init) + if (queryHook !== undefined) { + collectVariableNames(node.id, queryHook) } - collectVariableNames(node.id, node.init.callee.name) } }, CallExpression: (node) => { From aa2d019f9e3b2ded421a3aaa78108c87137dd0ed Mon Sep 17 00:00:00 2001 From: junjuny0227 Date: Tue, 19 May 2026 10:56:01 +0900 Subject: [PATCH 2/3] fix(eslint-plugin-query): handle later custom hook wrappers --- .../src/__tests__/no-unstable-deps.test.ts | 44 ++++++++++++ .../no-unstable-deps/no-unstable-deps.rule.ts | 69 +++++++++++++------ 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts index 3650afb6ba4..94c6d4c8ff6 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts @@ -207,6 +207,50 @@ const baseTestCases = { }, ], }, + { + name: `result of later custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useMutation } from "@tanstack/react-query"; + + function Component() { + const mutation = useMyMutation(); + const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]); + return; + } + + const useMyMutation = () => useMutation({ mutationFn: (value: string) => value }); + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useMutation' }, + }, + ], + }, + { + name: `result of later custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + import { useQuery } from "@tanstack/react-query"; + + function Component() { + const query = useMyQuery(); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]); + return; + } + + function useMyQuery() { + return useQuery({ queryFn: (value: string) => value }); + } + `, + errors: [ + { + messageId: 'noUnstableDeps', + data: { reactHook: reactHookAlias, queryHook: 'useQuery' }, + }, + ], + }, ]), } diff --git a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts index 590a6a2bd7c..46992718164 100644 --- a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts @@ -38,6 +38,11 @@ export const rule = createRule({ const trackedVariables: Record = {} const trackedCustomHooks: Record = {} const hookAliasMap: Record = {} + const pendingVariableDeclarators: Array = [] + const pendingDependencyChecks: Array<{ + reactHook: string + depsArray: TSESTree.ArrayExpression + }> = [] function getReactHook(node: TSESTree.CallExpression): string | undefined { if (node.callee.type === 'Identifier') { @@ -154,6 +159,29 @@ export const rule = createRule({ return undefined } + function checkDependencyArray( + reactHook: string, + depsArray: TSESTree.ArrayExpression, + ) { + depsArray.elements.forEach((dep) => { + if ( + dep !== null && + dep.type === AST_NODE_TYPES.Identifier && + trackedVariables[dep.name] !== undefined + ) { + const queryHook = trackedVariables[dep.name] + context.report({ + node: dep, + messageId: 'noUnstableDeps', + data: { + queryHook, + reactHook, + }, + }) + } + }) + } + return { ImportDeclaration(node: TSESTree.ImportDeclaration) { if ( @@ -203,10 +231,7 @@ export const rule = createRule({ node.init !== null && node.init.type === AST_NODE_TYPES.CallExpression ) { - const queryHook = getTrackedQueryHook(node.init) - if (queryHook !== undefined) { - collectVariableNames(node.id, queryHook) - } + pendingVariableDeclarators.push(node) } }, CallExpression: (node) => { @@ -216,26 +241,28 @@ export const rule = createRule({ node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression ) { - const depsArray = node.arguments[1].elements - depsArray.forEach((dep) => { - if ( - dep !== null && - dep.type === AST_NODE_TYPES.Identifier && - trackedVariables[dep.name] !== undefined - ) { - const queryHook = trackedVariables[dep.name] - context.report({ - node: dep, - messageId: 'noUnstableDeps', - data: { - queryHook, - reactHook, - }, - }) - } + pendingDependencyChecks.push({ + reactHook, + depsArray: node.arguments[1], }) } }, + 'Program:exit'() { + pendingVariableDeclarators.forEach((node) => { + if (node.init?.type !== AST_NODE_TYPES.CallExpression) { + return + } + + const queryHook = getTrackedQueryHook(node.init) + if (queryHook !== undefined) { + collectVariableNames(node.id, queryHook) + } + }) + + pendingDependencyChecks.forEach(({ reactHook, depsArray }) => { + checkDependencyArray(reactHook, depsArray) + }) + }, } }), }) From 43e744b9553349bdda23bd64149183b2c6c587b8 Mon Sep 17 00:00:00 2001 From: junjuny0227 Date: Tue, 19 May 2026 11:25:37 +0900 Subject: [PATCH 3/3] test(eslint-plugin-query): avoid TDZ in wrapper test --- .../src/__tests__/no-unstable-deps.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts index 94c6d4c8ff6..5bcff08aea2 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts @@ -219,7 +219,9 @@ const baseTestCases = { return; } - const useMyMutation = () => useMutation({ mutationFn: (value: string) => value }); + function useMyMutation() { + return useMutation({ mutationFn: (value: string) => value }); + } `, errors: [ {