Skip to content

Commit c29d520

Browse files
authored
Merge branch 'main' into fix/query-devtools-setup-stylesheet-cross-target-dedup
2 parents 0e6a46e + 2c4d9b1 commit c29d520

7 files changed

Lines changed: 539 additions & 33 deletions

File tree

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,96 @@ const baseTestCases = {
200200
},
201201
],
202202
},
203+
{
204+
name: `result of custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`,
205+
code: `
206+
${reactHookImport}
207+
import { useMutation } from "@tanstack/react-query";
208+
209+
const useMyMutation = () => useMutation({ mutationFn: (value: string) => value });
210+
211+
function Component() {
212+
const mutation = useMyMutation();
213+
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
214+
return;
215+
}
216+
`,
217+
errors: [
218+
{
219+
messageId: 'noUnstableDeps',
220+
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
221+
},
222+
],
223+
},
224+
{
225+
name: `result of custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`,
226+
code: `
227+
${reactHookImport}
228+
import { useQuery } from "@tanstack/react-query";
229+
230+
function useMyQuery() {
231+
return useQuery({ queryFn: (value: string) => value });
232+
}
233+
234+
function Component() {
235+
const query = useMyQuery();
236+
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
237+
return;
238+
}
239+
`,
240+
errors: [
241+
{
242+
messageId: 'noUnstableDeps',
243+
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
244+
},
245+
],
246+
},
247+
{
248+
name: `result of later custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`,
249+
code: `
250+
${reactHookImport}
251+
import { useMutation } from "@tanstack/react-query";
252+
253+
function Component() {
254+
const mutation = useMyMutation();
255+
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
256+
return;
257+
}
258+
259+
function useMyMutation() {
260+
return useMutation({ mutationFn: (value: string) => value });
261+
}
262+
`,
263+
errors: [
264+
{
265+
messageId: 'noUnstableDeps',
266+
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
267+
},
268+
],
269+
},
270+
{
271+
name: `result of later custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`,
272+
code: `
273+
${reactHookImport}
274+
import { useQuery } from "@tanstack/react-query";
275+
276+
function Component() {
277+
const query = useMyQuery();
278+
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
279+
return;
280+
}
281+
282+
function useMyQuery() {
283+
return useQuery({ queryFn: (value: string) => value });
284+
}
285+
`,
286+
errors: [
287+
{
288+
messageId: 'noUnstableDeps',
289+
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
290+
},
291+
],
292+
},
203293
])
204294
.concat([
205295
{

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

Lines changed: 143 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ 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> = {}
41+
const pendingVariableDeclarators: Array<TSESTree.VariableDeclarator> = []
42+
const pendingDependencyChecks: Array<{
43+
reactHook: string
44+
depsArray: TSESTree.ArrayExpression
45+
}> = []
4046

4147
function getReactHook(node: TSESTree.CallExpression): string | undefined {
4248
if (node.callee.type === 'Identifier') {
@@ -81,6 +87,10 @@ export const rule = createRule({
8187
}
8288
}
8389

90+
function isCustomHookName(hookName: string): boolean {
91+
return /^use[A-Z0-9]/.test(hookName)
92+
}
93+
8494
function hasCombineProperty(
8595
callExpression: TSESTree.CallExpression,
8696
): boolean {
@@ -98,6 +108,95 @@ export const rule = createRule({
98108
)
99109
}
100110

111+
function getDirectQueryHook(
112+
callExpression: TSESTree.CallExpression,
113+
): string | undefined {
114+
if (
115+
callExpression.callee.type !== AST_NODE_TYPES.Identifier ||
116+
!allHookNames.includes(callExpression.callee.name) ||
117+
!helpers.isTanstackQueryImport(callExpression.callee)
118+
) {
119+
return undefined
120+
}
121+
122+
if (
123+
(callExpression.callee.name === 'useQueries' ||
124+
callExpression.callee.name === 'useSuspenseQueries') &&
125+
hasCombineProperty(callExpression)
126+
) {
127+
return undefined
128+
}
129+
130+
return callExpression.callee.name
131+
}
132+
133+
function getTrackedQueryHook(
134+
callExpression: TSESTree.CallExpression,
135+
): string | undefined {
136+
const directQueryHook = getDirectQueryHook(callExpression)
137+
if (directQueryHook !== undefined) {
138+
return directQueryHook
139+
}
140+
141+
if (callExpression.callee.type === AST_NODE_TYPES.Identifier) {
142+
return trackedCustomHooks[callExpression.callee.name]
143+
}
144+
145+
return undefined
146+
}
147+
148+
function getReturnedQueryHook(
149+
body:
150+
| TSESTree.FunctionExpression['body']
151+
| TSESTree.ArrowFunctionExpression['body'],
152+
): string | undefined {
153+
if (body.type === AST_NODE_TYPES.CallExpression) {
154+
return getDirectQueryHook(body)
155+
}
156+
157+
if (body.type !== AST_NODE_TYPES.BlockStatement) {
158+
return undefined
159+
}
160+
161+
const returnStatements = body.body.filter(
162+
(statement): statement is TSESTree.ReturnStatement =>
163+
statement.type === AST_NODE_TYPES.ReturnStatement,
164+
)
165+
if (returnStatements.length !== 1) {
166+
return undefined
167+
}
168+
169+
const returnArgument = returnStatements[0]?.argument
170+
if (returnArgument?.type === AST_NODE_TYPES.CallExpression) {
171+
return getDirectQueryHook(returnArgument)
172+
}
173+
174+
return undefined
175+
}
176+
177+
function checkDependencyArray(
178+
reactHook: string,
179+
depsArray: TSESTree.ArrayExpression,
180+
) {
181+
depsArray.elements.forEach((dep) => {
182+
if (
183+
dep !== null &&
184+
dep.type === AST_NODE_TYPES.Identifier &&
185+
trackedVariables[dep.name] !== undefined
186+
) {
187+
const queryHook = trackedVariables[dep.name]
188+
context.report({
189+
node: dep,
190+
messageId: 'noUnstableDeps',
191+
data: {
192+
queryHook,
193+
reactHook,
194+
},
195+
})
196+
}
197+
})
198+
}
199+
101200
return {
102201
ImportDeclaration(node: TSESTree.ImportDeclaration) {
103202
if (
@@ -118,24 +217,36 @@ export const rule = createRule({
118217
}
119218
},
120219

220+
FunctionDeclaration(node) {
221+
if (node.id === null || !isCustomHookName(node.id.name)) {
222+
return
223+
}
224+
225+
const queryHook = getReturnedQueryHook(node.body)
226+
if (queryHook !== undefined) {
227+
trackedCustomHooks[node.id.name] = queryHook
228+
}
229+
},
230+
121231
VariableDeclarator(node) {
122232
if (
233+
node.id.type === AST_NODE_TYPES.Identifier &&
234+
isCustomHookName(node.id.name) &&
123235
node.init !== null &&
124-
node.init.type === AST_NODE_TYPES.CallExpression &&
125-
node.init.callee.type === AST_NODE_TYPES.Identifier &&
126-
allHookNames.includes(node.init.callee.name) &&
127-
helpers.isTanstackQueryImport(node.init.callee)
236+
(node.init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
237+
node.init.type === AST_NODE_TYPES.FunctionExpression)
128238
) {
129-
// Special case for useQueries/useSuspenseQueries with combine property - it's stable
130-
if (
131-
(node.init.callee.name === 'useQueries' ||
132-
node.init.callee.name === 'useSuspenseQueries') &&
133-
hasCombineProperty(node.init)
134-
) {
135-
// Don't track useQueries/useSuspenseQueries with combine as unstable
136-
return
239+
const queryHook = getReturnedQueryHook(node.init.body)
240+
if (queryHook !== undefined) {
241+
trackedCustomHooks[node.id.name] = queryHook
137242
}
138-
collectVariableNames(node.id, node.init.callee.name)
243+
}
244+
245+
if (
246+
node.init !== null &&
247+
node.init.type === AST_NODE_TYPES.CallExpression
248+
) {
249+
pendingVariableDeclarators.push(node)
139250
}
140251
},
141252
CallExpression: (node) => {
@@ -145,26 +256,28 @@ export const rule = createRule({
145256
node.arguments.length > 1 &&
146257
node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression
147258
) {
148-
const depsArray = node.arguments[1].elements
149-
depsArray.forEach((dep) => {
150-
if (
151-
dep !== null &&
152-
dep.type === AST_NODE_TYPES.Identifier &&
153-
trackedVariables[dep.name] !== undefined
154-
) {
155-
const queryHook = trackedVariables[dep.name]
156-
context.report({
157-
node: dep,
158-
messageId: 'noUnstableDeps',
159-
data: {
160-
queryHook,
161-
reactHook,
162-
},
163-
})
164-
}
259+
pendingDependencyChecks.push({
260+
reactHook,
261+
depsArray: node.arguments[1],
165262
})
166263
}
167264
},
265+
'Program:exit'() {
266+
pendingVariableDeclarators.forEach((node) => {
267+
if (node.init?.type !== AST_NODE_TYPES.CallExpression) {
268+
return
269+
}
270+
271+
const queryHook = getTrackedQueryHook(node.init)
272+
if (queryHook !== undefined) {
273+
collectVariableNames(node.id, queryHook)
274+
}
275+
})
276+
277+
pendingDependencyChecks.forEach(({ reactHook, depsArray }) => {
278+
checkDependencyArray(reactHook, depsArray)
279+
})
280+
},
168281
}
169282
}),
170283
})

packages/preact-query-devtools/src/__tests__/PreactQueryDevtools.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ describe('PreactQueryDevtools', () => {
6262
expect(mountMock).toHaveBeenCalled()
6363
})
6464

65+
it('should forward "buttonPosition" to the devtools instance', async () => {
66+
const { PreactQueryDevtools } = await import('../PreactQueryDevtools')
67+
const queryClient = new QueryClient()
68+
69+
render(
70+
<PreactQueryDevtools client={queryClient} buttonPosition="top-left" />,
71+
)
72+
73+
expect(setButtonPositionMock).toHaveBeenCalledWith('top-left')
74+
})
75+
76+
it('should forward "position" to the devtools instance', async () => {
77+
const { PreactQueryDevtools } = await import('../PreactQueryDevtools')
78+
const queryClient = new QueryClient()
79+
80+
render(<PreactQueryDevtools client={queryClient} position="left" />)
81+
82+
expect(setPositionMock).toHaveBeenCalledWith('left')
83+
})
84+
6585
it('should return null in non-development environments', async () => {
6686
vi.stubEnv('NODE_ENV', 'production')
6787
vi.resetModules()

packages/react-query-devtools/src/__tests__/ReactQueryDevtools.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ describe('ReactQueryDevtools', () => {
6262
expect(mountMock).toHaveBeenCalled()
6363
})
6464

65+
it('should forward "buttonPosition" to the devtools instance', async () => {
66+
const { ReactQueryDevtools } = await import('../ReactQueryDevtools')
67+
const queryClient = new QueryClient()
68+
69+
render(
70+
<ReactQueryDevtools client={queryClient} buttonPosition="top-left" />,
71+
)
72+
73+
expect(setButtonPositionMock).toHaveBeenCalledWith('top-left')
74+
})
75+
76+
it('should forward "position" to the devtools instance', async () => {
77+
const { ReactQueryDevtools } = await import('../ReactQueryDevtools')
78+
const queryClient = new QueryClient()
79+
80+
render(<ReactQueryDevtools client={queryClient} position="left" />)
81+
82+
expect(setPositionMock).toHaveBeenCalledWith('left')
83+
})
84+
6585
it('should return null in non-development environments', async () => {
6686
vi.stubEnv('NODE_ENV', 'production')
6787
vi.resetModules()

0 commit comments

Comments
 (0)