Skip to content

Commit f8d65fb

Browse files
feat(eslint-plugin-query): add rule to ensure property order of infinite query functions (#8072)
* feat(eslint-plugin-query): add rule to ensure property order of infinite query functions * remove outdated comment --------- Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc>
1 parent ca9e3c4 commit f8d65fb

File tree

14 files changed

+728
-30
lines changed

14 files changed

+728
-30
lines changed

docs/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,10 @@
796796
{
797797
"label": "No Unstable Deps",
798798
"to": "eslint/no-unstable-deps"
799+
},
800+
{
801+
"label": "Infinite Query Property Order",
802+
"to": "eslint/infinite-query-property-order"
799803
}
800804
]
801805
},
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
id: infinite-query-property-order
3+
title: Ensure correct order of inference sensitive properties for infinite queries
4+
---
5+
6+
For the following functions, the property order of the passed in object matters due to type inference:
7+
8+
- `useInfiniteQuery`
9+
- `useSuspenseInfiniteQuery`
10+
- `infiniteQueryOptions`
11+
12+
The correct property order is as follows:
13+
14+
- `queryFn`
15+
- `getPreviousPageParam`
16+
- `getNextPageParam`
17+
18+
All other properties are insensitive to the order as they do not depend on type inference.
19+
20+
## Rule Details
21+
22+
Examples of **incorrect** code for this rule:
23+
24+
```tsx
25+
/* eslint "@tanstack/query/infinite-query-property-order": "warn" */
26+
import { useInfiniteQuery } from '@tanstack/react-query'
27+
28+
const query = useInfiniteQuery({
29+
queryKey: ['projects'],
30+
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
31+
queryFn: async ({ pageParam }) => {
32+
const response = await fetch(`/api/projects?cursor=${pageParam}`)
33+
return await response.json()
34+
},
35+
initialPageParam: 0,
36+
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
37+
maxPages: 3,
38+
})
39+
```
40+
41+
Examples of **correct** code for this rule:
42+
43+
```tsx
44+
/* eslint "@tanstack/query/infinite-query-property-order": "warn" */
45+
import { useInfiniteQuery } from '@tanstack/react-query'
46+
47+
const query = useInfiniteQuery({
48+
queryKey: ['projects'],
49+
queryFn: async ({ pageParam }) => {
50+
const response = await fetch(`/api/projects?cursor=${pageParam}`)
51+
return await response.json()
52+
},
53+
initialPageParam: 0,
54+
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
55+
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
56+
maxPages: 3,
57+
})
58+
```
59+
60+
## Attributes
61+
62+
- [x] ✅ Recommended
63+
- [x] 🔧 Fixable

packages/eslint-plugin-query/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
},
5555
"devDependencies": {
5656
"@typescript-eslint/rule-tester": "^8.3.0",
57+
"combinate": "^1.1.11",
5758
"eslint": "^9.9.1"
5859
},
5960
"peerDependencies": {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester'
2+
import combinate from 'combinate'
3+
4+
import {
5+
checkedProperties,
6+
infiniteQueryFunctions,
7+
} from '../rules/infinite-query-property-order/constants'
8+
import {
9+
name,
10+
rule,
11+
} from '../rules/infinite-query-property-order/infinite-query-property-order.rule'
12+
import {
13+
generateInterleavedCombinations,
14+
generatePartialCombinations,
15+
generatePermutations,
16+
} from './test-utils'
17+
import type { InfiniteQueryFunctions } from '../rules/infinite-query-property-order/constants'
18+
19+
const ruleTester = new RuleTester()
20+
21+
type CheckedProperties = (typeof checkedProperties)[number]
22+
const orderIndependentProps = ['queryKey', '...foo'] as const
23+
type OrderIndependentProps = (typeof orderIndependentProps)[number]
24+
25+
interface TestCase {
26+
infiniteQueryFunction: InfiniteQueryFunctions
27+
properties: Array<CheckedProperties | OrderIndependentProps>
28+
}
29+
30+
const validTestMatrix = combinate({
31+
infiniteQueryFunction: [...infiniteQueryFunctions],
32+
properties: generatePartialCombinations(checkedProperties, 2),
33+
})
34+
35+
export function generateInvalidPermutations<T>(
36+
arr: ReadonlyArray<T>,
37+
): Array<{ invalid: Array<T>; valid: Array<T> }> {
38+
const combinations = generatePartialCombinations(arr, 2)
39+
const allPermutations: Array<{ invalid: Array<T>; valid: Array<T> }> = []
40+
41+
for (const combination of combinations) {
42+
const permutations = generatePermutations(combination)
43+
// skip the first permutation as it matches the original combination
44+
const invalidPermutations = permutations.slice(1)
45+
allPermutations.push(
46+
...invalidPermutations.map((p) => ({ invalid: p, valid: combination })),
47+
)
48+
}
49+
50+
return allPermutations
51+
}
52+
53+
const invalidPermutations = generateInvalidPermutations(checkedProperties)
54+
55+
type Interleaved = CheckedProperties | OrderIndependentProps
56+
const interleavedInvalidPermutations: Array<{
57+
invalid: Array<Interleaved>
58+
valid: Array<Interleaved>
59+
}> = []
60+
for (const invalidPermutation of invalidPermutations) {
61+
const invalid = generateInterleavedCombinations(
62+
invalidPermutation.invalid,
63+
orderIndependentProps,
64+
)
65+
const valid = generateInterleavedCombinations(
66+
invalidPermutation.valid,
67+
orderIndependentProps,
68+
)
69+
70+
for (let i = 0; i < invalid.length; i++) {
71+
interleavedInvalidPermutations.push({
72+
invalid: invalid[i]!,
73+
valid: valid[i]!,
74+
})
75+
}
76+
}
77+
78+
const invalidTestMatrix = combinate({
79+
infiniteQueryFunction: [...infiniteQueryFunctions],
80+
properties: interleavedInvalidPermutations,
81+
})
82+
83+
function getCode({
84+
infiniteQueryFunction: infiniteQueryFunction,
85+
properties,
86+
}: TestCase) {
87+
function getPropertyCode(
88+
property: CheckedProperties | OrderIndependentProps,
89+
) {
90+
if (property.startsWith('...')) {
91+
return property
92+
}
93+
switch (property) {
94+
case 'queryKey':
95+
return `queryKey: ['projects']`
96+
case 'queryFn':
97+
return 'queryFn: async ({ pageParam }) => { \n await fetch(`/api/projects?cursor=${pageParam}`) \n return await response.json() \n }'
98+
case 'getPreviousPageParam':
99+
return 'getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined'
100+
case 'getNextPageParam':
101+
return 'getNextPageParam: (lastPage) => lastPage.nextId ?? undefined'
102+
}
103+
104+
return `${property}: () => null`
105+
}
106+
return `
107+
import { ${infiniteQueryFunction} } from '@tanstack/react-query'
108+
109+
${infiniteQueryFunction}({
110+
${properties.map(getPropertyCode).join(',\n ')}
111+
})
112+
`
113+
}
114+
115+
const validTestCases = validTestMatrix.map(
116+
({ infiniteQueryFunction, properties }) => ({
117+
name: `should pass when order is correct for ${infiniteQueryFunction} with order: ${properties.join(', ')}`,
118+
code: getCode({ infiniteQueryFunction, properties }),
119+
}),
120+
)
121+
122+
const invalidTestCases = invalidTestMatrix.map(
123+
({ infiniteQueryFunction, properties }) => ({
124+
name: `incorrect property order is detected for ${infiniteQueryFunction} with order: ${properties.invalid.join(', ')}`,
125+
code: getCode({
126+
infiniteQueryFunction: infiniteQueryFunction,
127+
properties: properties.invalid,
128+
}),
129+
errors: [{ messageId: 'invalidOrder' }],
130+
output: getCode({
131+
infiniteQueryFunction: infiniteQueryFunction,
132+
properties: properties.valid,
133+
}),
134+
}),
135+
)
136+
137+
ruleTester.run(name, rule, {
138+
valid: validTestCases,
139+
invalid: invalidTestCases,
140+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { sortDataByOrder } from '../rules/infinite-query-property-order/infinite-query-property-order.utils'
3+
4+
describe('create-route-property-order utils', () => {
5+
describe('sortDataByOrder', () => {
6+
const testCases = [
7+
{
8+
data: [{ key: 'a' }, { key: 'c' }, { key: 'b' }],
9+
orderArray: ['a', 'b', 'c'],
10+
key: 'key',
11+
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
12+
},
13+
{
14+
data: [{ key: 'b' }, { key: 'a' }, { key: 'c' }],
15+
orderArray: ['a', 'b', 'c'],
16+
key: 'key',
17+
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
18+
},
19+
{
20+
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
21+
orderArray: ['a', 'b', 'c'],
22+
key: 'key',
23+
expected: null,
24+
},
25+
{
26+
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'd' }],
27+
orderArray: ['a', 'b', 'c'],
28+
key: 'key',
29+
expected: null,
30+
},
31+
{
32+
data: [{ key: 'a' }, { key: 'b' }, { key: 'd' }, { key: 'c' }],
33+
orderArray: ['a', 'b', 'c'],
34+
key: 'key',
35+
expected: null,
36+
},
37+
{
38+
data: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
39+
orderArray: ['a', 'b', 'c'],
40+
key: 'key',
41+
expected: null,
42+
},
43+
{
44+
data: [{ key: 'd' }, { key: 'b' }, { key: 'a' }, { key: 'c' }],
45+
orderArray: ['a', 'b', 'c'],
46+
key: 'key',
47+
expected: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
48+
},
49+
] as const
50+
test.each(testCases)(
51+
'$data $orderArray $key $expected',
52+
({ data, orderArray, key, expected }) => {
53+
const sortedData = sortDataByOrder(data, orderArray, key)
54+
expect(sortedData).toEqual(expected)
55+
},
56+
)
57+
})
58+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, test } from 'vitest'
2+
import {
3+
expectArrayEqualIgnoreOrder,
4+
generateInterleavedCombinations,
5+
generatePartialCombinations,
6+
generatePermutations,
7+
} from './test-utils'
8+
9+
describe('test-utils', () => {
10+
describe('generatePermutations', () => {
11+
const testCases = [
12+
{
13+
input: ['a', 'b', 'c'],
14+
expected: [
15+
['a', 'b', 'c'],
16+
['a', 'c', 'b'],
17+
['b', 'a', 'c'],
18+
['b', 'c', 'a'],
19+
['c', 'a', 'b'],
20+
['c', 'b', 'a'],
21+
],
22+
},
23+
{
24+
input: ['a', 'b'],
25+
expected: [
26+
['a', 'b'],
27+
['b', 'a'],
28+
],
29+
},
30+
{
31+
input: ['a'],
32+
expected: [['a']],
33+
},
34+
]
35+
test.each(testCases)('$input $expected', ({ input, expected }) => {
36+
const permutations = generatePermutations(input)
37+
expect(permutations).toEqual(expected)
38+
})
39+
})
40+
41+
describe('generatePartialCombinations', () => {
42+
const testCases = [
43+
{
44+
input: ['a', 'b', 'c'],
45+
minLength: 2,
46+
expected: [
47+
['a', 'b'],
48+
['a', 'c'],
49+
['b', 'c'],
50+
['a', 'b', 'c'],
51+
],
52+
},
53+
{
54+
input: ['a', 'b'],
55+
expected: [['a', 'b']],
56+
minLength: 2,
57+
},
58+
{
59+
input: ['a'],
60+
expected: [],
61+
minLength: 2,
62+
},
63+
{
64+
input: ['a'],
65+
expected: [['a']],
66+
minLength: 1,
67+
},
68+
{
69+
input: ['a'],
70+
expected: [[], ['a']],
71+
minLength: 0,
72+
},
73+
]
74+
test.each(testCases)(
75+
'$input $minLength $expected',
76+
({ input, minLength, expected }) => {
77+
const combinations = generatePartialCombinations(input, minLength)
78+
expectArrayEqualIgnoreOrder(combinations, expected)
79+
},
80+
)
81+
})
82+
83+
describe('generateInterleavedCombinations', () => {
84+
const testCases = [
85+
{
86+
data: ['a', 'b'],
87+
additional: ['x'],
88+
expected: [
89+
['a', 'b'],
90+
['x', 'a', 'b'],
91+
['a', 'x', 'b'],
92+
['a', 'b', 'x'],
93+
],
94+
},
95+
]
96+
test.each(testCases)(
97+
'$input $expected',
98+
({ data, additional, expected }) => {
99+
const combinations = generateInterleavedCombinations(data, additional)
100+
expectArrayEqualIgnoreOrder(combinations, expected)
101+
},
102+
)
103+
})
104+
})

0 commit comments

Comments
 (0)