Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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