Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,74 @@ const baseTestCases = {
},
],
},
])
.concat([
{
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";

const useMyQuery = () =>
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' },
},
],
},
{
name: `result of custom useQuery wrapper in useMemo is passed to dependency array`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";

const useTodoQuery = (queryKeyValue: string) =>
useQuery({ queryKey: ['todo', queryKeyValue], queryFn: () => queryKeyValue });

function Component({ queryKeyValue }: { queryKeyValue: string }) {
const query = useTodoQuery(queryKeyValue);
const value = ${reactHookInvocation}(() => query.data, [query]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
},
],
},
]),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export const rule = createRule({
create: detectTanstackQueryImports((context, _options, helpers) => {
const trackedVariables: Record<string, string> = {}
const hookAliasMap: Record<string, string> = {}
const trackedCustomHooks: Record<string, string> = {}
const pendingCustomHookAssignments: Array<{
id: TSESTree.BindingName
calleeName: string
}> = []
const reactHookDependencies: Array<{
node: TSESTree.Identifier
reactHook: string
}> = []

function getReactHook(node: TSESTree.CallExpression): string | undefined {
if (node.callee.type === 'Identifier') {
Expand Down Expand Up @@ -98,6 +107,71 @@ export const rule = createRule({
)
}

function getReturnedHookName(
expression: TSESTree.Expression,
): string | undefined {
if (expression.type === AST_NODE_TYPES.ParenthesizedExpression) {
return getReturnedHookName(expression.expression)
}

if (expression.type !== AST_NODE_TYPES.CallExpression) {
return undefined
}

if (
expression.callee.type !== AST_NODE_TYPES.Identifier ||
!expression.callee.name.startsWith('use') ||
!helpers.isTanstackQueryImport(expression.callee)
) {
return undefined
}

return expression.callee.name
}

function getFunctionReturnedHookName(
node:
| TSESTree.ArrowFunctionExpression
| TSESTree.FunctionDeclaration
| TSESTree.FunctionExpression,
): string | undefined {
const returnedNode =
node.body.type === AST_NODE_TYPES.BlockStatement
? node.body.body.find(
(statement) =>
statement.type === AST_NODE_TYPES.ReturnStatement &&
statement.argument !== null,
)?.argument
: node.body

if (!returnedNode) {
return undefined
}

return getReturnedHookName(returnedNode)
}

function resolveTrackedHook(
calleeName: string,
visited: Set<string> = new Set(),
): string | undefined {
if (allHookNames.includes(calleeName)) {
return calleeName
}

if (visited.has(calleeName)) {
return undefined
}

const nestedHookName = trackedCustomHooks[calleeName]
if (!nestedHookName) {
return undefined
}

visited.add(calleeName)
return resolveTrackedHook(nestedHookName, visited)
}

return {
ImportDeclaration(node: TSESTree.ImportDeclaration) {
if (
Expand All @@ -118,24 +192,65 @@ export const rule = createRule({
}
},

FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
if (!node.id || node.id.type !== AST_NODE_TYPES.Identifier) {
return
}

const returnedHookName = getFunctionReturnedHookName(node)
if (!returnedHookName) {
return
}

trackedCustomHooks[node.id.name] = returnedHookName
},

VariableDeclarator(node) {
if (
node.id.type === AST_NODE_TYPES.Identifier &&
node.id.name.startsWith('use') &&
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.FunctionExpression ||
node.init.type === AST_NODE_TYPES.ArrowFunctionExpression)
) {
// Special case for useQueries/useSuspenseQueries with combine property - it's stable
const returnedHookName = getFunctionReturnedHookName(node.init)
if (!returnedHookName) {
return
}

trackedCustomHooks[node.id.name] = returnedHookName
}

if (
node.init?.type !== AST_NODE_TYPES.CallExpression ||
node.init.callee.type !== AST_NODE_TYPES.Identifier ||
!node.init.callee.name.startsWith('use')
) {
return
}

const calleeName = node.init.callee.name
const resolvedQueryHook = resolveTrackedHook(calleeName)

if (allHookNames.includes(calleeName)) {
if (!helpers.isTanstackQueryImport(node.init.callee)) {
return
}

if (
(node.init.callee.name === 'useQueries' ||
node.init.callee.name === 'useSuspenseQueries') &&
(calleeName === 'useQueries' ||
calleeName === 'useSuspenseQueries') &&
hasCombineProperty(node.init)
) {
// Don't track useQueries/useSuspenseQueries with combine as unstable
return
}
collectVariableNames(node.id, node.init.callee.name)

collectVariableNames(node.id, calleeName)
return
}

if (resolvedQueryHook) {
pendingCustomHookAssignments.push({ id: node.id, calleeName })
}
Comment on lines +252 to 254
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Forward-reference custom hooks won't be tracked.

The condition if (resolvedQueryHook) only pushes to pendingCustomHookAssignments when the hook is already resolvable at visit time. Since ESLint visits nodes in tree order, a custom hook defined after its usage in the file won't be in trackedCustomHooks yet, causing resolveTrackedHook to return undefined and skipping the push. This defeats the purpose of the deferred mechanism.

Example that would be missed:

function Component() {
  const query = useMyQuery(); // visited first
  useEffect(() => {}, [query]); // false negative
}
function useMyQuery() { return useQuery({...}); } // visited second
🐛 Proposed fix: Always defer non-TanStack `use*` calls
-        if (resolvedQueryHook) {
-          pendingCustomHookAssignments.push({ id: node.id, calleeName })
-        }
+        // Always defer - custom hook may be defined later in file
+        pendingCustomHookAssignments.push({ id: node.id, calleeName })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts`
around lines 252 - 254, The current check only pushes to
pendingCustomHookAssignments when resolveTrackedHook (resolvedQueryHook) is
truthy, which misses forward-referenced custom hooks; change the logic in the
visitor handling use* calls so that any non-TanStack call (i.e., custom hook
candidates determined by calleeName / isTanStackHook) is always added to
pendingCustomHookAssignments (push { id: node.id, calleeName }) for deferred
resolution, instead of gating the push on resolvedQueryHook; keep
resolveTrackedHook/trackedCustomHooks usage for later resolution but ensure
pendingCustomHookAssignments is populated unconditionally for custom hooks to
handle forward references.

},
CallExpression: (node) => {
Expand All @@ -147,24 +262,40 @@ export const rule = createRule({
) {
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,
},
})
if (dep !== null && dep.type === AST_NODE_TYPES.Identifier) {
reactHookDependencies.push({ node: dep, reactHook })
}
})
}
},

'Program:exit'() {
pendingCustomHookAssignments.forEach(({ id, calleeName }) => {
const queryHook = resolveTrackedHook(calleeName)
if (!queryHook) {
return
}

collectVariableNames(id, queryHook)
})

reactHookDependencies.forEach(({ node, reactHook }) => {
const queryHook = trackedVariables[node.name]

if (!queryHook) {
return
}

context.report({
node,
messageId: 'noUnstableDeps',
data: {
queryHook,
reactHook,
},
})
})
},
}
}),
})