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
5 changes: 5 additions & 0 deletions .changeset/perky-seas-flash.md
Original file line number Diff line number Diff line change
@@ -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.
343 changes: 329 additions & 14 deletions packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand Down Expand Up @@ -259,6 +245,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`
Expand Down Expand Up @@ -423,6 +425,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: `
Expand Down Expand Up @@ -587,8 +633,94 @@ 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: [
{
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`
Expand Down Expand Up @@ -888,6 +1020,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`
Expand All @@ -906,6 +1081,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`
Expand Down Expand Up @@ -1084,5 +1281,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')
}
},
})
}
`,
},
],
},
],
},
],
})
Loading
Loading