Skip to content

Commit 16050b3

Browse files
committed
fix(eslint-plugin-query): track custom query hook wrappers
1 parent 3e85350 commit 16050b3

2 files changed

Lines changed: 142 additions & 12 deletions

File tree

packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,50 @@ const baseTestCases = {
163163
},
164164
],
165165
},
166+
{
167+
name: `result of custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`,
168+
code: `
169+
${reactHookImport}
170+
import { useMutation } from "@tanstack/react-query";
171+
172+
const useMyMutation = () => useMutation({ mutationFn: (value: string) => value });
173+
174+
function Component() {
175+
const mutation = useMyMutation();
176+
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
177+
return;
178+
}
179+
`,
180+
errors: [
181+
{
182+
messageId: 'noUnstableDeps',
183+
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
184+
},
185+
],
186+
},
187+
{
188+
name: `result of custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`,
189+
code: `
190+
${reactHookImport}
191+
import { useQuery } from "@tanstack/react-query";
192+
193+
function useMyQuery() {
194+
return useQuery({ queryFn: (value: string) => value });
195+
}
196+
197+
function Component() {
198+
const query = useMyQuery();
199+
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
200+
return;
201+
}
202+
`,
203+
errors: [
204+
{
205+
messageId: 'noUnstableDeps',
206+
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
207+
},
208+
],
209+
},
166210
]),
167211
}
168212

packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const rule = createRule({
3636

3737
create: detectTanstackQueryImports((context, _options, helpers) => {
3838
const trackedVariables: Record<string, string> = {}
39+
const trackedCustomHooks: Record<string, string> = {}
3940
const hookAliasMap: Record<string, string> = {}
4041

4142
function getReactHook(node: TSESTree.CallExpression): string | undefined {
@@ -67,6 +68,10 @@ export const rule = createRule({
6768
}
6869
}
6970

71+
function isCustomHookName(hookName: string): boolean {
72+
return /^use[A-Z0-9]/.test(hookName)
73+
}
74+
7075
function hasCombineProperty(
7176
callExpression: TSESTree.CallExpression,
7277
): boolean {
@@ -84,6 +89,71 @@ export const rule = createRule({
8489
)
8590
}
8691

92+
function getDirectQueryHook(
93+
callExpression: TSESTree.CallExpression,
94+
): string | undefined {
95+
if (
96+
callExpression.callee.type !== AST_NODE_TYPES.Identifier ||
97+
!allHookNames.includes(callExpression.callee.name) ||
98+
!helpers.isTanstackQueryImport(callExpression.callee)
99+
) {
100+
return undefined
101+
}
102+
103+
if (
104+
callExpression.callee.name === 'useQueries' &&
105+
hasCombineProperty(callExpression)
106+
) {
107+
return undefined
108+
}
109+
110+
return callExpression.callee.name
111+
}
112+
113+
function getTrackedQueryHook(
114+
callExpression: TSESTree.CallExpression,
115+
): string | undefined {
116+
const directQueryHook = getDirectQueryHook(callExpression)
117+
if (directQueryHook !== undefined) {
118+
return directQueryHook
119+
}
120+
121+
if (callExpression.callee.type === AST_NODE_TYPES.Identifier) {
122+
return trackedCustomHooks[callExpression.callee.name]
123+
}
124+
125+
return undefined
126+
}
127+
128+
function getReturnedQueryHook(
129+
body:
130+
| TSESTree.FunctionExpression['body']
131+
| TSESTree.ArrowFunctionExpression['body'],
132+
): string | undefined {
133+
if (body.type === AST_NODE_TYPES.CallExpression) {
134+
return getDirectQueryHook(body)
135+
}
136+
137+
if (body.type !== AST_NODE_TYPES.BlockStatement) {
138+
return undefined
139+
}
140+
141+
const returnStatements = body.body.filter(
142+
(statement): statement is TSESTree.ReturnStatement =>
143+
statement.type === AST_NODE_TYPES.ReturnStatement,
144+
)
145+
if (returnStatements.length !== 1) {
146+
return undefined
147+
}
148+
149+
const returnArgument = returnStatements[0]?.argument
150+
if (returnArgument?.type === AST_NODE_TYPES.CallExpression) {
151+
return getDirectQueryHook(returnArgument)
152+
}
153+
154+
return undefined
155+
}
156+
87157
return {
88158
ImportDeclaration(node: TSESTree.ImportDeclaration) {
89159
if (
@@ -104,23 +174,39 @@ export const rule = createRule({
104174
}
105175
},
106176

177+
FunctionDeclaration(node) {
178+
if (node.id === null || !isCustomHookName(node.id.name)) {
179+
return
180+
}
181+
182+
const queryHook = getReturnedQueryHook(node.body)
183+
if (queryHook !== undefined) {
184+
trackedCustomHooks[node.id.name] = queryHook
185+
}
186+
},
187+
107188
VariableDeclarator(node) {
189+
if (
190+
node.id.type === AST_NODE_TYPES.Identifier &&
191+
isCustomHookName(node.id.name) &&
192+
node.init !== null &&
193+
(node.init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
194+
node.init.type === AST_NODE_TYPES.FunctionExpression)
195+
) {
196+
const queryHook = getReturnedQueryHook(node.init.body)
197+
if (queryHook !== undefined) {
198+
trackedCustomHooks[node.id.name] = queryHook
199+
}
200+
}
201+
108202
if (
109203
node.init !== null &&
110-
node.init.type === AST_NODE_TYPES.CallExpression &&
111-
node.init.callee.type === AST_NODE_TYPES.Identifier &&
112-
allHookNames.includes(node.init.callee.name) &&
113-
helpers.isTanstackQueryImport(node.init.callee)
204+
node.init.type === AST_NODE_TYPES.CallExpression
114205
) {
115-
// Special case for useQueries with combine property - it's stable
116-
if (
117-
node.init.callee.name === 'useQueries' &&
118-
hasCombineProperty(node.init)
119-
) {
120-
// Don't track useQueries with combine as unstable
121-
return
206+
const queryHook = getTrackedQueryHook(node.init)
207+
if (queryHook !== undefined) {
208+
collectVariableNames(node.id, queryHook)
122209
}
123-
collectVariableNames(node.id, node.init.callee.name)
124210
}
125211
},
126212
CallExpression: (node) => {

0 commit comments

Comments
 (0)