snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"ignoreRules": ["false-export-default"]
}

View File

@@ -0,0 +1,177 @@
# @tanstack/eslint-plugin-query
## 5.102.8
## 5.102.7
## 5.102.6
## 5.102.5
## 5.102.4
## 5.102.3
## 5.102.2
## 5.102.1
## 5.102.0
### Patch Changes
- [#11212](https://github.com/TanStack/query/pull/11212) [`279bb55`](https://github.com/TanStack/query/commit/279bb5508b81ce0d88b8f0f72441d96abf7ca03c) - TypeScript 7 compatibility
- [#11188](https://github.com/TanStack/query/pull/11188) [`3bc8edf`](https://github.com/TanStack/query/commit/3bc8edfa0199e6467458084e8802a99bce11b958) - Fix false-positive warnings in `no-unstable-deps` rule caused by `Object.prototype` property lookups (`toString`, `valueOf`, etc.).
- [#11115](https://github.com/TanStack/query/pull/11115) [`8582702`](https://github.com/TanStack/query/commit/858270215247d1dbdbe9d9a00068b4ffa5218da6) - Remove unused helper functions from the internal `ASTUtils` object (`getNestedIdentifiers`, `getNestedReturnStatements`, `getClosestVariableDeclarator`, `mapKeyNodeToText`, and `mapKeyNodeToBaseText`). They were not referenced by any rule and are not part of the public API.
## 5.101.4
### Patch Changes
- [#11067](https://github.com/TanStack/query/pull/11067) [`7ac45ed`](https://github.com/TanStack/query/commit/7ac45ed45cbc28a54ac2fcfb1faccfcd87fb7b75) - Relax `exhaustive-deps` so function call targets are not required in query keys while values referenced in nested callbacks are still checked.
## 5.101.3
## 5.101.2
## 5.101.1
## 5.101.0
### Minor Changes
- [#10775](https://github.com/TanStack/query/pull/10775) [`dc54932`](https://github.com/TanStack/query/commit/dc549328642e3d5b7417c0ad070d9fbf38c195a4) - `no-rest-destructuring` now also flags rest destructuring on custom hooks that return a TanStack Query result. Detection uses the TypeScript type checker and runs only when typed linting is enabled, so untyped projects are unaffected. Closes [#8951](https://github.com/TanStack/query/issues/8951).
## 5.100.14
## 5.100.13
## 5.100.12
### Patch Changes
- fix(no-unstable-deps): handle array-destructured useQueries and useSuspenseQueries results ([#10747](https://github.com/TanStack/query/pull/10747))
- Fix `no-unstable-deps` false positives for `useSuspenseQueries` results returned from `combine`. ([#10642](https://github.com/TanStack/query/pull/10642))
## 5.100.11
## 5.100.10
## 5.100.9
## 5.100.8
## 5.100.7
## 5.100.6
## 5.100.5
## 5.100.4
## 5.100.3
## 5.100.2
## 5.100.1
## 5.100.0
## 5.99.2
## 5.99.1
### Patch Changes
- fix(eslint-plugin-query): fix `no-void-query-fn` false positive on enum returns for typescript 6. ([#10460](https://github.com/TanStack/query/pull/10460))
## 5.99.0
## 5.98.0
## 5.97.0
### Minor Changes
- feat(eslint): support eslint v10 and typescript v6 ([#10182](https://github.com/TanStack/query/pull/10182))
## 5.96.2
### Patch Changes
- fix(eslint-plugin): normalize whitespace in allowList variable matching for multiline expressions ([#10337](https://github.com/TanStack/query/pull/10337))
## 5.96.1
## 5.96.0
### Minor Changes
- Add `prefer-query-options` rule and `recommendedStrict` config ([#10359](https://github.com/TanStack/query/pull/10359))
## 5.95.2
## 5.95.1
## 5.95.0
### Minor Changes
- BREAKING (eslint-plugin): The `exhaustive-deps` rule now reports member expression dependencies more granularly for call expressions (e.g. `a.b.foo()` suggests `a.b`), which may cause existing code that previously passed the rule to now report missing dependencies. To accommodate stable variables and types, the rule now accepts an `allowlist` option with `variables` and `types` arrays to exclude specific dependencies from enforcement. ([#10295](https://github.com/TanStack/query/pull/10295))
## 5.94.5
### Patch Changes
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
## 5.94.4
### Patch Changes
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
## 5.91.5
### Patch Changes
- 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. ([#10258](https://github.com/TanStack/query/pull/10258))
## 5.91.4
### Patch Changes
- fix(eslint-plugin-query): declare typescript as optional peer dependency ([#10007](https://github.com/TanStack/query/pull/10007))
## 5.91.3
### Patch Changes
- exhaustive-deps rule fixed for vue files ([#10011](https://github.com/TanStack/query/pull/10011))
## 5.91.2
### Patch Changes
- fix: allow useQueries with combine property in no-unstable-deps rule ([#9720](https://github.com/TanStack/query/pull/9720))
## 5.91.1
### Patch Changes
- avoid typescript import in no-void-query-fn rule ([#9759](https://github.com/TanStack/query/pull/9759))
## 5.91.0
### Minor Changes
- feat: improve type of exported plugin ([#9700](https://github.com/TanStack/query/pull/9700))
## 5.90.2
### Patch Changes
- fix: exhaustive-deps with variables and type assertions ([#9687](https://github.com/TanStack/query/pull/9687))

View File

@@ -0,0 +1,20 @@
// @ts-check
import vitest from '@vitest/eslint-plugin'
import rootConfig from './root.eslint.config.js'
export default [
...rootConfig,
{
plugins: { vitest },
rules: {
...vitest.configs.recommended.rules,
'vitest/expect-expect': [
'warn',
{
assertFunctionNames: ['expect', 'expectArrayEqualIgnoreOrder'],
},
],
},
},
]

View File

@@ -0,0 +1,77 @@
{
"name": "@tanstack/eslint-plugin-query",
"version": "5.102.8",
"description": "ESLint plugin for TanStack Query",
"author": "Eliya Cohen",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/query.git",
"directory": "packages/eslint-plugin-query"
},
"homepage": "https://tanstack.com/query",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"scripts": {
"clean": "premove ./dist ./coverage ./dist-ts ./.cache/test-types",
"compile": "tsc --project tsconfig.json",
"test:eslint": "eslint --concurrency=auto ./src",
"test:types": "npm-run-all --serial test:types:*",
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts56 --tsBuildInfoFile .cache/test-types/ts56.tsbuildinfo",
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts57 --tsBuildInfoFile .cache/test-types/ts57.tsbuildinfo",
"test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts58 --tsBuildInfoFile .cache/test-types/ts58.tsbuildinfo",
"test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts59 --tsBuildInfoFile .cache/test-types/ts59.tsbuildinfo",
"test:types:tscurrent": "tsc --project tsconfig.json --outDir .cache/test-types/tscurrent --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
"test:types:ts70": "node ../../node_modules/typescript70/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts70 --tsBuildInfoFile .cache/test-types/ts70.tsbuildinfo",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsdown --tsconfig tsconfig.prod.json"
},
"type": "module",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"react-native": "src/index.ts",
"exports": {
".": {
"@tanstack/custom-condition": "./src/index.ts",
"import": {
"types": "./build/modern/index.d.ts",
"default": "./build/modern/index.js"
},
"require": {
"types": "./build/modern/index.d.cts",
"default": "./build/modern/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"build",
"src",
"!src/__tests__"
],
"dependencies": {
"@typescript-eslint/utils": "^8.58.1"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.48.0",
"@typescript-eslint/rule-tester": "^8.48.0",
"combinate": "^1.1.11",
"eslint": "^9.36.0",
"npm-run-all2": "^5.0.0"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": "^5.6.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}

View File

@@ -0,0 +1 @@
../../eslint.config.js

View File

@@ -0,0 +1,49 @@
// @ts-check
/**
* @param {Object} opts - Options for building configurations.
* @param {string[]} opts.entry - The entry array.
* @returns {import('tsdown').UserConfig}
*/
export function modernConfig(opts) {
return {
entry: opts.entry,
format: ['cjs', 'esm'],
target: ['chrome91', 'firefox90', 'edge91', 'safari15', 'ios15', 'opera77'],
outDir: 'build/modern',
dts: true,
fixedExtension: false,
sourcemap: true,
clean: true,
deps: { neverBundle: ['typescript'] },
footer: ({ format, fileName }) =>
format === 'cjs' && fileName.endsWith('index.cjs')
? 'module.exports = module.exports.default'
: undefined,
outputOptions: {
exports: 'named',
},
}
}
/**
* @param {Object} opts - Options for building configurations.
* @param {string[]} opts.entry - The entry array.
* @returns {import('tsdown').UserConfig}
*/
export function legacyConfig(opts) {
return {
entry: opts.entry,
format: ['cjs', 'esm'],
target: ['es2020', 'node16'],
outDir: 'build/legacy',
dts: true,
fixedExtension: false,
sourcemap: true,
clean: true,
deps: { neverBundle: ['typescript'] },
outputOptions: {
exports: 'named',
},
}
}

View File

@@ -0,0 +1,53 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import { describe, expect, it } from 'vitest'
import { ExhaustiveDepsUtils } from '../rules/exhaustive-deps/exhaustive-deps.utils'
import { ASTUtils } from '../utils/ast-utils'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
function createIdentifier(name: string): TSESTree.Identifier {
return { type: AST_NODE_TYPES.Identifier, name } as TSESTree.Identifier
}
describe('ASTUtils', () => {
it('stops member traversal when a node has no parent', () => {
const identifier = createIdentifier('value')
expect(ASTUtils.traverseUpMemberExpression(identifier)).toBe(identifier)
})
it('handles an external reference without a parent', () => {
const operation = createIdentifier('operation')
const reference = {
identifier: operation,
isRead: () => true,
resolved: null,
} as TSESLint.Scope.Reference
const scope = {
childScopes: [],
references: [reference],
set: new Map(),
} as unknown as TSESLint.Scope.Scope
const scopeManager = {
acquire: () => scope,
} as unknown as TSESLint.Scope.ScopeManager
const sourceCode = {
getText: () => 'operation',
} as unknown as Readonly<TSESLint.SourceCode>
expect(
ASTUtils.getExternalRefs({
scopeManager,
sourceCode,
node: operation,
}),
).toEqual([reference])
})
})
describe('ExhaustiveDepsUtils', () => {
it('does not treat a detached identifier as a function call target', () => {
expect(
ExhaustiveDepsUtils.isFunctionCallTarget(createIdentifier('fetchTodos')),
).toBe(false)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import combinate from 'combinate'
import {
checkedProperties,
infiniteQueryFunctions,
} from '../rules/infinite-query-property-order/constants'
import {
name,
rule,
} from '../rules/infinite-query-property-order/infinite-query-property-order.rule'
import {
generateInterleavedCombinations,
generatePartialCombinations,
generatePermutations,
normalizeIndent,
} from './test-utils'
import type { InfiniteQueryFunctions } from '../rules/infinite-query-property-order/constants'
const ruleTester = new RuleTester()
type CheckedProperties = (typeof checkedProperties)[number]
const orderIndependentProps = [
'queryKey',
'...objectExpressionSpread',
'...callExpressionSpread',
'...memberCallExpressionSpread',
] as const
type OrderIndependentProps = (typeof orderIndependentProps)[number]
interface TestCase {
infiniteQueryFunction: InfiniteQueryFunctions
properties: Array<CheckedProperties | OrderIndependentProps>
}
const validTestMatrix = combinate({
infiniteQueryFunction: [...infiniteQueryFunctions],
properties: generatePartialCombinations(checkedProperties, 2),
})
export function generateInvalidPermutations(
arr: ReadonlyArray<CheckedProperties>,
): Array<{
invalid: Array<CheckedProperties>
valid: Array<CheckedProperties>
}> {
const combinations = generatePartialCombinations(arr, 2)
const allPermutations: Array<{
invalid: Array<CheckedProperties>
valid: Array<CheckedProperties>
}> = []
for (const combination of combinations) {
const permutations = generatePermutations(combination)
// skip the first permutation as it matches the original combination
const invalidPermutations = permutations.slice(1)
if (
combination.includes('getNextPageParam') &&
combination.includes('getPreviousPageParam')
) {
if (
combination.indexOf('getNextPageParam') <
combination.indexOf('getPreviousPageParam')
) {
// since we ignore the relative order of 'getPreviousPageParam' and 'getNextPageParam', we skip this combination (but keep the other one where `getPreviousPageParam` is before `getNextPageParam`)
continue
}
}
allPermutations.push(
...invalidPermutations
.map((p) => {
// ignore the relative order of 'getPreviousPageParam' and 'getNextPageParam'
const correctedValid = [...combination].sort((a, b) => {
if (
(a === 'getNextPageParam' && b === 'getPreviousPageParam') ||
(a === 'getPreviousPageParam' && b === 'getNextPageParam')
) {
return p.indexOf(a) - p.indexOf(b)
}
return checkedProperties.indexOf(a) - checkedProperties.indexOf(b)
})
return { invalid: p, valid: correctedValid }
})
.filter(
({ invalid }) =>
// if `getPreviousPageParam` and `getNextPageParam` are next to each other and `queryFn` is not present, we skip this invalid permutation
Math.abs(
invalid.indexOf('getNextPageParam') -
invalid.indexOf('getPreviousPageParam'),
) !== 1,
),
)
}
return allPermutations
}
const invalidPermutations = generateInvalidPermutations(checkedProperties)
type Interleaved = CheckedProperties | OrderIndependentProps
const interleavedInvalidPermutations: Array<{
invalid: Array<Interleaved>
valid: Array<Interleaved>
}> = []
for (const invalidPermutation of invalidPermutations) {
const invalid = generateInterleavedCombinations(
invalidPermutation.invalid,
orderIndependentProps,
)
const valid = generateInterleavedCombinations(
invalidPermutation.valid,
orderIndependentProps,
)
for (let i = 0; i < invalid.length; i++) {
interleavedInvalidPermutations.push({
invalid: invalid[i]!,
valid: valid[i]!,
})
}
}
const invalidTestMatrix = combinate({
infiniteQueryFunction: [...infiniteQueryFunctions],
properties: interleavedInvalidPermutations,
})
const callExpressionSpread = normalizeIndent`
...communitiesQuery({
filters: {
...fieldValues,
placementFormats: [],
},
})`
function getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties,
}: TestCase) {
function getPropertyCode(
property: CheckedProperties | OrderIndependentProps,
) {
switch (property) {
case '...objectExpressionSpread':
return `...objectExpressionSpread`
case '...callExpressionSpread':
return callExpressionSpread
case '...memberCallExpressionSpread':
return '...myOptions.infiniteQueryOptions()'
case 'queryKey':
return `queryKey: ['projects']`
case 'queryFn':
return 'queryFn: async ({ pageParam }) => { \n await fetch(`/api/projects?cursor=${pageParam}`) \n return await response.json() \n }'
case 'getPreviousPageParam':
return 'getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined'
case 'getNextPageParam':
return 'getNextPageParam: (lastPage) => lastPage.nextId ?? undefined'
}
}
return `
import { ${infiniteQueryFunction} } from '@tanstack/react-query'
${infiniteQueryFunction}({
${properties.map(getPropertyCode).join(',\n ')}
})
`
}
const validTestCases = validTestMatrix.map(
({ infiniteQueryFunction, properties }) => ({
name: `should pass when order is correct for ${infiniteQueryFunction} with order: ${properties.join(', ')}`,
code: getCode({ infiniteQueryFunction, properties }),
}),
)
const invalidTestCases = invalidTestMatrix.map(
({ infiniteQueryFunction, properties }) => ({
name: `incorrect property order is detected for ${infiniteQueryFunction} with invalid order: ${properties.invalid.join(', ')}, valid order: ${properties.valid.join(', ')}`,
code: getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties: properties.invalid,
}),
errors: [{ messageId: 'invalidOrder' }],
output: getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties: properties.valid,
}),
}),
)
ruleTester.run(name, rule, {
valid: validTestCases,
invalid: invalidTestCases,
})
// regression tests
const regressionTestCases = {
valid: [
{
name: 'should pass with call expression spread',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
const { data, isFetching, isLoading, hasNextPage, fetchNextPage } =
useInfiniteQuery({
...communitiesQuery({
filters: {
...fieldValues,
placementFormats: [],
},
}),
refetchOnMount: false,
})`,
},
],
invalid: [],
}
ruleTester.run(name, rule, regressionTestCases)

View File

@@ -0,0 +1,218 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import combinate from 'combinate'
import {
checkedProperties,
mutationFunctions,
} from '../rules/mutation-property-order/constants'
import {
name,
rule,
} from '../rules/mutation-property-order/mutation-property-order.rule'
import {
generateInterleavedCombinations,
generatePartialCombinations,
generatePermutations,
normalizeIndent,
} from './test-utils'
import type { MutationFunctions } from '../rules/mutation-property-order/constants'
const ruleTester = new RuleTester()
type CheckedProperties = (typeof checkedProperties)[number]
const orderIndependentProps = [
'gcTime',
'...objectExpressionSpread',
'...callExpressionSpread',
'...memberCallExpressionSpread',
] as const
type OrderIndependentProps = (typeof orderIndependentProps)[number]
interface TestCase {
mutationFunction: MutationFunctions
properties: Array<CheckedProperties | OrderIndependentProps>
}
const validTestMatrix = combinate({
mutationFunction: [...mutationFunctions],
properties: generatePartialCombinations(checkedProperties, 2),
})
export function generateInvalidPermutations(
arr: ReadonlyArray<CheckedProperties>,
): Array<{
invalid: Array<CheckedProperties>
valid: Array<CheckedProperties>
}> {
const combinations = generatePartialCombinations(arr, 2)
const allPermutations: Array<{
invalid: Array<CheckedProperties>
valid: Array<CheckedProperties>
}> = []
for (const combination of combinations) {
const permutations = generatePermutations(combination)
// skip the first permutation as it matches the original combination
const invalidPermutations = permutations.slice(1)
if (combination.includes('onError') && combination.includes('onSettled')) {
if (combination.indexOf('onError') < combination.indexOf('onSettled')) {
// since we ignore the relative order of 'onError' and 'onSettled', we skip this combination (but keep the other one where `onSettled` is before `onError`)
continue
}
}
allPermutations.push(
...invalidPermutations
.map((p) => {
// ignore the relative order of 'onError' and 'onSettled'
const correctedValid = [...combination].sort((a, b) => {
if (
(a === 'onSettled' && b === 'onError') ||
(a === 'onError' && b === 'onSettled')
) {
return p.indexOf(a) - p.indexOf(b)
}
return checkedProperties.indexOf(a) - checkedProperties.indexOf(b)
})
return { invalid: p, valid: correctedValid }
})
.filter(
({ invalid }) =>
// if `onError` and `onSettled` are next to each other and `onMutate` is not present, we skip this invalid permutation
Math.abs(
invalid.indexOf('onSettled') - invalid.indexOf('onError'),
) !== 1,
),
)
}
return allPermutations
}
const invalidPermutations = generateInvalidPermutations(checkedProperties)
type Interleaved = CheckedProperties | OrderIndependentProps
const interleavedInvalidPermutations: Array<{
invalid: Array<Interleaved>
valid: Array<Interleaved>
}> = []
for (const invalidPermutation of invalidPermutations) {
const invalid = generateInterleavedCombinations(
invalidPermutation.invalid,
orderIndependentProps,
)
const valid = generateInterleavedCombinations(
invalidPermutation.valid,
orderIndependentProps,
)
for (let i = 0; i < invalid.length; i++) {
interleavedInvalidPermutations.push({
invalid: invalid[i]!,
valid: valid[i]!,
})
}
}
const invalidTestMatrix = combinate({
mutationFunction: [...mutationFunctions],
properties: interleavedInvalidPermutations,
})
const callExpressionSpread = normalizeIndent`
...mutationOptions({
onSuccess: () => {},
retry: 3,
})`
function getCode({ mutationFunction: mutationFunction, properties }: TestCase) {
function getPropertyCode(
property: CheckedProperties | OrderIndependentProps,
) {
switch (property) {
case '...objectExpressionSpread':
return `...objectExpressionSpread`
case '...callExpressionSpread':
return callExpressionSpread
case '...memberCallExpressionSpread':
return '...myOptions.mutationOptions()'
case 'gcTime':
return 'gcTime: 5 * 60 * 1000'
case 'onMutate':
return 'onMutate: (data) => {\n return { foo: data }\n}'
case 'onError':
return 'onError: (error, variables, onMutateResult) => {\n console.log("error:", error, "onMutateResult:", scope)\n}'
case 'onSettled':
return 'onSettled: (data, error, variables, onMutateResult) => {\n console.log("settled", onMutateResult)\n}'
}
}
return `
import { ${mutationFunction} } from '@tanstack/react-query'
${mutationFunction}({
${properties.map(getPropertyCode).join(',\n ')}
})
`
}
const validTestCases = validTestMatrix.map(
({ mutationFunction, properties }) => ({
name: `should pass when order is correct for ${mutationFunction} with order: ${properties.join(', ')}`,
code: getCode({
mutationFunction: mutationFunction,
properties,
}),
}),
)
const invalidTestCases = invalidTestMatrix.map(
({ mutationFunction, properties }) => ({
name: `incorrect property order id detected for ${mutationFunction} with invalid order: ${properties.invalid.join(', ')}, valid order ${properties.valid.join(', ')}`,
code: getCode({
mutationFunction: mutationFunction,
properties: properties.invalid,
}),
errors: [{ messageId: 'invalidOrder' }],
output: getCode({
mutationFunction: mutationFunction,
properties: properties.valid,
}),
}),
)
ruleTester.run(name, rule, {
valid: validTestCases,
invalid: invalidTestCases,
})
const regressionTestCases = {
valid: [
{
name: 'should pass with call expression spread in useMutation',
code: normalizeIndent`
import { useMutation } from '@tanstack/react-query'
const { mutate } = useMutation({
...mutationOptions({
retry: 3,
onSuccess: () => console.log('success'),
}),
onMutate: (data) => {
return { foo: data }
},
onError: (error, variables, onMutateResult) => {
console.log(error, onMutateResult)
},
onSettled: (data, error, variables, onMutateResult) => {
console.log('settled', onMutateResult)
},
})
`,
},
],
invalid: [],
}
ruleTester.run(name, rule, regressionTestCases)

View File

@@ -0,0 +1,506 @@
import path from 'node:path'
import { RuleTester } from '@typescript-eslint/rule-tester'
import { afterAll, describe, it } from 'vitest'
import { rule } from '../rules/no-rest-destructuring/no-rest-destructuring.rule'
import { normalizeIndent } from './test-utils'
RuleTester.afterAll = afterAll
RuleTester.describe = describe
RuleTester.it = it
const ruleTester = new RuleTester()
ruleTester.run('no-rest-destructuring', rule, {
valid: [
{
name: 'useQuery is not captured',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
useQuery()
return
}
`,
},
{
name: 'useQuery is not destructured',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery()
return
}
`,
},
{
name: 'useQuery is destructured without rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const { data, isLoading, isError } = useQuery()
return
}
`,
},
{
name: 'useInfiniteQuery is not captured',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
useInfiniteQuery()
return
}
`,
},
{
name: 'useInfiniteQuery is not destructured',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
const query = useInfiniteQuery()
return
}
`,
},
{
name: 'useInfiniteQuery is destructured without rest',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
const { data, isLoading, isError } = useInfiniteQuery()
return
}
`,
},
{
name: 'useQueries is not captured',
code: normalizeIndent`
import { useQueries } from '@tanstack/react-query'
function Component() {
useQueries([])
return
}
`,
},
{
name: 'useQueries is not destructured',
code: normalizeIndent`
import { useQueries } from '@tanstack/react-query'
function Component() {
const queries = useQueries([])
return
}
`,
},
{
name: 'useQueries array has no rest destructured element',
code: normalizeIndent`
import { useQueries } from '@tanstack/react-query'
function Component() {
const [query1, { data, isLoading },, ...others] = useQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
{ queryKey: ['key3'], queryFn: () => {} },
{ queryKey: ['key4'], queryFn: () => {} },
{ queryKey: ['key5'], queryFn: () => {} },
])
return
}
`,
},
{
name: 'useQuery is destructured with rest but not from tanstack query',
code: normalizeIndent`
import { useQuery } from 'other-package'
function Component() {
const { data, ...rest } = useQuery()
return
}
`,
},
{
name: 'useInfiniteQuery is destructured with rest but not from tanstack query',
code: normalizeIndent`
import { useInfiniteQuery } from 'other-package'
function Component() {
const { data, ...rest } = useInfiniteQuery()
return
}
`,
},
{
name: 'useQueries array has rest destructured element but not from tanstack query',
code: normalizeIndent`
import { useQueries } from 'other-package'
function Component() {
const [query1, { data, ...rest }] = useQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
])
return
}
`,
},
{
name: 'useSuspenseQuery is not captured',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
useSuspenseQuery()
return
}
`,
},
{
name: 'useSuspenseQuery is not destructured',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
const query = useSuspenseQuery()
return
}
`,
},
{
name: 'useSuspenseQuery is destructured without rest',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
const { data, isLoading, isError } = useSuspenseQuery()
return
}
`,
},
{
name: 'useSuspenseInfiniteQuery is not captured',
code: normalizeIndent`
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
function Component() {
useSuspenseInfiniteQuery()
return
}
`,
},
{
name: 'useSuspenseInfiniteQuery is not destructured',
code: normalizeIndent`
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
function Component() {
const query = useSuspenseInfiniteQuery()
return
}
`,
},
{
name: 'useSuspenseInfiniteQuery is destructured without rest',
code: normalizeIndent`
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
function Component() {
const { data, isLoading, isError } = useSuspenseInfiniteQuery()
return
}
`,
},
{
name: 'useSuspenseQueries is not captured',
code: normalizeIndent`
import { useSuspenseQueries } from '@tanstack/react-query'
function Component() {
useSuspenseQueries([])
return
}
`,
},
{
name: 'useSuspenseQueries is not destructured',
code: normalizeIndent`
import { useSuspenseQueries } from '@tanstack/react-query'
function Component() {
const queries = useSuspenseQueries([])
return
}
`,
},
{
name: 'useSuspenseQueries array has no rest destructured element',
code: normalizeIndent`
import { useSuspenseQueries } from '@tanstack/react-query'
function Component() {
const [query1, { data, isLoading }] = useSuspenseQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
])
return
}
`,
},
{
name: 'useSuspenseQuery is destructured with rest but not from tanstack query',
code: normalizeIndent`
import { useSuspenseQuery } from 'other-package'
function Component() {
const { data, ...rest } = useSuspenseQuery()
return
}
`,
},
{
name: 'useSuspenseInfiniteQuery is destructured with rest but not from tanstack query',
code: normalizeIndent`
import { useSuspenseInfiniteQuery } from 'other-package'
function Component() {
const { data, ...rest } = useSuspenseInfiniteQuery()
return
}
`,
},
{
name: 'useSuspenseQueries array has rest destructured element but not from tanstack query',
code: normalizeIndent`
import { useSuspenseQueries } from 'other-package'
function Component() {
const [query1, { data, ...rest }] = useSuspenseQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
])
return
}
`,
},
],
invalid: [
{
name: 'useQuery is destructured with rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const { data, ...rest } = useQuery()
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useInfiniteQuery is destructured with rest',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
const { data, ...rest } = useInfiniteQuery()
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useQueries array has rest destructured element',
code: normalizeIndent`
import { useQueries } from '@tanstack/react-query'
function Component() {
const [query1, { data, ...rest }] = useQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
])
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useSuspenseQuery is destructured with rest',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
const { data, ...rest } = useSuspenseQuery()
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useSuspenseInfiniteQuery is destructured with rest',
code: normalizeIndent`
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
function Component() {
const { data, ...rest } = useSuspenseInfiniteQuery()
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useSuspenseQueries is destructured with rest',
code: normalizeIndent`
import { useSuspenseQueries } from '@tanstack/react-query'
function Component() {
const [query1, { data, ...rest }] = useSuspenseQueries([
{ queryKey: ['key1'], queryFn: () => {} },
{ queryKey: ['key2'], queryFn: () => {} },
])
return
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useQuery result is spread in return statement',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery()
return { ...query, data: query.data[0] }
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'useQuery result is spread in object expression',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery()
const result = { ...query, data: query.data[0] }
return result
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
],
})
const ruleTesterTypeChecked = new RuleTester({
languageOptions: {
parser: await import('@typescript-eslint/parser'),
parserOptions: {
project: true,
tsconfigRootDir: path.resolve(__dirname, './ts-fixture'),
},
},
})
ruleTesterTypeChecked.run('no-rest-destructuring with type information', rule, {
valid: [
{
name: 'custom hook not returning a query result is destructured with rest',
code: normalizeIndent`
const useThing = () => ({ data: 1, isError: false })
function Component() {
const { data, ...rest } = useThing()
return null
}
`,
},
{
name: 'custom hook returning a query result is destructured without rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
const useTodos = () =>
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
function Component() {
const { data, isLoading } = useTodos()
return null
}
`,
},
],
invalid: [
{
name: 'custom hook returning useQuery is destructured with rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
const useTodos = () =>
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
function Component() {
const { data, ...rest } = useTodos()
return null
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'custom hook result is spread in object expression',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
const useTodos = () =>
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
function Component() {
const todosQuery = useTodos()
return { ...todosQuery }
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'custom hook result is assigned then destructured with rest',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
const useTodos = () =>
useQuery({ queryKey: ['todos'], queryFn: () => Promise.resolve([]) })
function Component() {
const todosQuery = useTodos()
const { data, ...rest } = todosQuery
return null
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
{
name: 'custom hook returning an interface query result is destructured with rest',
code: normalizeIndent`
import type { QueryObserverResult } from '@tanstack/react-query'
const useTodos = (): QueryObserverResult => ({
data: undefined,
isLoading: false,
isError: false,
})
function Component() {
const { data, ...rest } = useTodos()
return null
}
`,
errors: [{ messageId: 'objectRestDestructure' }],
},
],
})

View File

@@ -0,0 +1,456 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import {
reactHookNames,
rule,
useQueryHookNames,
} from '../rules/no-unstable-deps/no-unstable-deps.rule'
const ruleTester = new RuleTester()
interface TestCase {
reactHookImport: string
reactHookInvocation: string
reactHookAlias: string
}
const baseTestCases = {
valid: ({ reactHookImport, reactHookInvocation, reactHookAlias }: TestCase) =>
[
{
name: `should pass when destructured mutate is passed to ${reactHookAlias} as dependency`,
code: `
${reactHookImport}
import { useMutation } from "@tanstack/react-query";
function Component() {
const { mutate } = useMutation({ mutationFn: (value: string) => value });
const callback = ${reactHookInvocation}(() => { mutate('hello') }, [mutate]);
return;
}
`,
},
]
.concat(
useQueryHookNames.map((queryHook) => ({
name: `should pass result of ${queryHook} is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { ${queryHook} } from "@tanstack/react-query";
function Component() {
const { refetch } = ${queryHook}({ queryFn: (value: string) => value });
const callback = ${reactHookInvocation}(() => { query.refetch() }, [refetch]);
return;
}
`,
})),
)
.concat([
{
name: `should pass when useQueries with combine is passed to ${reactHookAlias} as dependency`,
code: `
${reactHookImport}
import { useQueries } from "@tanstack/react-query";
function Component() {
const queries = useQueries({
queries: [
{ queryKey: ['test'], queryFn: () => 'test' }
],
combine: (results) => ({ data: results[0]?.data })
});
const callback = ${reactHookInvocation}(() => { queries.data }, [queries]);
return;
}
`,
},
{
name: `should pass when useSuspenseQueries with combine is passed to ${reactHookAlias} as dependency`,
code: `
${reactHookImport}
import { useSuspenseQueries } from "@tanstack/react-query";
function Component() {
const queries = useSuspenseQueries({
queries: [
{ queryKey: ['test'], queryFn: () => 'test' }
],
combine: (results) => ({ data: results[0]?.data })
});
const callback = ${reactHookInvocation}(() => { queries.data }, [queries]);
return;
}
`,
},
])
.concat([
{
name: `should pass when useQueries is array-destructured and element properties are used with ${reactHookAlias}`,
code: `
${reactHookImport}
import { useQueries } from "@tanstack/react-query";
function Component() {
const [{ data }] = useQueries({
queries: [
{ queryKey: ['test'], queryFn: () => 'test' }
]
});
const callback = ${reactHookInvocation}(() => { data }, [data]);
return;
}
`,
},
])
.concat([
{
name: `should pass when useQuery is imported from non-TanStack source and used with ${reactHookAlias}`,
code: `
${reactHookImport}
import { useQuery } from "./router";
function Component() {
const query = useQuery();
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
return;
}
`,
},
{
name: `should pass when useMutation is imported from non-TanStack source and used with ${reactHookAlias}`,
code: `
${reactHookImport}
import { useMutation } from "./api";
function Component() {
const mutation = useMutation();
const callback = ${reactHookInvocation}(() => { mutation.mutate() }, [mutation]);
return;
}
`,
},
{
name: `should pass when functions or variables with Object.prototype names are used with ${reactHookAlias}`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";
function Component() {
const toString = () => 'str';
const valueOf = 42;
const callback = ${reactHookInvocation}(() => { toString() }, [toString, valueOf]);
return;
}
`,
},
{
name: `should pass when custom functions named after Object.prototype methods are invoked`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";
function toString() {
return 'test';
}
function Component() {
const res = toString();
const callback = ${reactHookInvocation}(() => { res }, [res]);
return;
}
`,
},
{
name: `should pass when a local function named after Object.prototype method is called alongside destructured query result with ${reactHookAlias}`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";
function toString() {
return 'formatted';
}
function Component() {
const { data } = useQuery({ queryKey: ['test'], queryFn: () => 'test' });
const formatted = toString();
const callback = ${reactHookInvocation}(() => { formatted }, [data, formatted]);
return;
}
`,
},
]),
invalid: ({
reactHookImport,
reactHookInvocation,
reactHookAlias,
}: TestCase) =>
[
{
name: `result of useMutation is passed to ${reactHookInvocation} as dependency `,
code: `
${reactHookImport}
import { useMutation } from "@tanstack/react-query";
function Component() {
const mutation = useMutation({ mutationFn: (value: string) => value });
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
},
],
},
]
.concat(
useQueryHookNames.map((queryHook) => ({
name: `result of ${queryHook} is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { ${queryHook} } from "@tanstack/react-query";
function Component() {
const query = ${queryHook}({ queryFn: (value: string) => value });
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook },
},
],
})),
)
.concat([
{
name: `result of useQueries without combine is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useQueries } from "@tanstack/react-query";
function Component() {
const queries = useQueries({
queries: [
{ queryKey: ['test'], queryFn: () => 'test' }
]
});
const callback = ${reactHookInvocation}(() => { queries[0]?.data }, [queries]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQueries' },
},
],
},
{
name: `result of custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useMutation } from "@tanstack/react-query";
const useMyMutation = () => useMutation({ mutationFn: (value: string) => value });
function Component() {
const mutation = useMyMutation();
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
},
],
},
{
name: `result of custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";
function useMyQuery() {
return useQuery({ queryFn: (value: string) => value });
}
function Component() {
const query = useMyQuery();
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
},
],
},
{
name: `result of later custom useMutation wrapper is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useMutation } from "@tanstack/react-query";
function Component() {
const mutation = useMyMutation();
const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
return;
}
function useMyMutation() {
return useMutation({ mutationFn: (value: string) => value });
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useMutation' },
},
],
},
{
name: `result of later custom useQuery wrapper is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useQuery } from "@tanstack/react-query";
function Component() {
const query = useMyQuery();
const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]);
return;
}
function useMyQuery() {
return useQuery({ queryFn: (value: string) => value });
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQuery' },
},
],
},
])
.concat([
{
name: `array-destructured useQueries element is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useQueries } from "@tanstack/react-query";
function Component() {
const [userQuery, postsQuery] = useQueries({
queries: [
{ queryKey: ['user'], queryFn: () => 'user' },
{ queryKey: ['posts'], queryFn: () => 'posts' }
]
});
const callback = ${reactHookInvocation}(() => { userQuery.data }, [userQuery]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQueries' },
},
],
},
{
name: `array-destructured useSuspenseQueries element is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useSuspenseQueries } from "@tanstack/react-query";
function Component() {
const [query] = useSuspenseQueries({
queries: [
{ queryKey: ['test'], queryFn: () => 'test' }
]
});
const callback = ${reactHookInvocation}(() => { query.data }, [query]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: {
reactHook: reactHookAlias,
queryHook: 'useSuspenseQueries',
},
},
],
},
{
name: `rest element of array-destructured useQueries is passed to ${reactHookInvocation} as dependency`,
code: `
${reactHookImport}
import { useQueries } from "@tanstack/react-query";
function Component() {
const [firstQuery, ...restQueries] = useQueries({
queries: [
{ queryKey: ['a'], queryFn: () => 'a' },
{ queryKey: ['b'], queryFn: () => 'b' }
]
});
const callback = ${reactHookInvocation}(() => {}, [restQueries]);
return;
}
`,
errors: [
{
messageId: 'noUnstableDeps',
data: { reactHook: reactHookAlias, queryHook: 'useQueries' },
},
],
},
]),
}
const testCases = (reactHookName: string) => [
{
reactHookImport: 'import * as React from "React";',
reactHookInvocation: `React.${reactHookName}`,
reactHookAlias: reactHookName,
},
{
reactHookImport: `import { ${reactHookName} } from "React";`,
reactHookInvocation: reactHookName,
reactHookAlias: reactHookName,
},
{
reactHookImport: `import { ${reactHookName} as useAlias } from "React";`,
reactHookInvocation: 'useAlias',
reactHookAlias: 'useAlias',
},
]
reactHookNames.forEach((reactHookName) => {
testCases(reactHookName).forEach(
({ reactHookInvocation, reactHookAlias, reactHookImport }) => {
ruleTester.run('no-unstable-deps', rule, {
valid: baseTestCases.valid({
reactHookImport,
reactHookInvocation,
reactHookAlias,
}),
invalid: baseTestCases.invalid({
reactHookImport,
reactHookInvocation,
reactHookAlias,
}),
})
},
)
})

View File

@@ -0,0 +1,635 @@
import path from 'node:path'
import { RuleTester } from '@typescript-eslint/rule-tester'
import { afterAll, describe, it } from 'vitest'
import { rule } from '../rules/no-void-query-fn/no-void-query-fn.rule'
import { normalizeIndent } from './test-utils'
RuleTester.afterAll = afterAll
RuleTester.describe = describe
RuleTester.it = it
const ruleTester = new RuleTester({
languageOptions: {
parser: await import('@typescript-eslint/parser'),
parserOptions: {
project: true,
tsconfigRootDir: path.resolve(__dirname, './ts-fixture'),
},
},
})
ruleTester.run('no-void-query-fn', rule, {
valid: [
{
name: 'queryFn returns a value',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => ({ data: 'test' }),
})
return null
}
`,
},
{
name: 'queryFn returns a Promise',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async () => ({ data: 'test' }),
})
return null
}
`,
},
{
name: 'queryFn returns Promise.resolve',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve({ data: 'test' }),
})
return null
}
`,
},
{
name: 'queryFn with explicit Promise type',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
interface Data {
value: string
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async (): Promise<Data> => {
return { value: 'test' }
},
})
return null
}
`,
},
{
name: 'queryFn with generic Promise type',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
interface Response<T> {
data: T
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async (): Promise<Response<string>> => {
return { data: 'test' }
},
})
return null
}
`,
},
{
name: 'queryFn with external async function',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
async function fetchData(): Promise<{ data: string }> {
return { data: 'test' }
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: fetchData,
})
return null
}
`,
},
{
name: 'queryFn returns null',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => null,
})
return null
}
`,
},
{
name: 'queryFn returns 0',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => 0,
})
return null
}
`,
},
{
name: 'queryFn returns false',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => false,
})
return null
}
`,
},
{
name: 'useInfiniteQuery queryFn returns a value',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
const query = useInfiniteQuery({
queryKey: ['test'],
queryFn: ({ pageParam }) => ({ data: 'test', page: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => undefined,
})
return null
}
`,
},
{
name: 'useSuspenseQuery queryFn returns a value',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
const query = useSuspenseQuery({
queryKey: ['test'],
queryFn: () => ({ data: 'test' }),
})
return null
}
`,
},
{
name: 'queryOptions queryFn returns a value',
code: normalizeIndent`
import { queryOptions } from '@tanstack/react-query'
const options = queryOptions({
queryKey: ['test'],
queryFn: () => ({ data: 'test' }),
})
`,
},
{
name: 'fetchQuery queryFn returns a value',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.fetchQuery({
queryKey: ['test'],
queryFn: () => fetch('/api/test').then((r) => r.json()),
})
`,
},
{
name: 'prefetchQuery queryFn returns a value',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.prefetchQuery({
queryKey: ['test'],
queryFn: () => fetch('/api/test').then((r) => r.json()),
})
`,
},
{
name: 'prefetchInfiniteQuery queryFn returns a value',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.prefetchInfiniteQuery({
queryKey: ['test'],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetch(\`/api/test?page=\${pageParam}\`).then((r) => r.json()),
initialPageParam: 0,
})
`,
},
{
name: 'ensureQueryData queryFn returns a value',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.ensureQueryData({
queryKey: ['test'],
queryFn: () => fetch('/api/test').then((r) => r.json()),
})
`,
},
{
name: 'ensureInfiniteQueryData queryFn returns a value',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.ensureInfiniteQueryData({
queryKey: ['test'],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetch(\`/api/test?page=\${pageParam}\`).then((r) => r.json()),
initialPageParam: 0,
})
`,
},
{
name: 'queryFn returns a numeric enum member',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
enum ExampleEnum {
A,
B,
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => ExampleEnum.A,
})
return null
}
`,
},
{
name: 'queryFn returns a string enum member',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
enum StringEnum {
Foo = 'foo',
Bar = 'bar',
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => StringEnum.Foo,
})
return null
}
`,
},
{
name: 'async queryFn returns a numeric enum member',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
enum Status {
Active,
Inactive,
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async () => {
return Status.Active
},
})
return null
}
`,
},
{
name: 'queryFn returns a const enum member',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
const enum Direction {
Up = 'UP',
Down = 'DOWN',
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => Direction.Up,
})
return null
}
`,
},
],
invalid: [
{
name: 'queryFn returns void',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => {
console.log('test')
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn returns undefined',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => undefined,
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'async queryFn returns void',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async () => {
await someOperation()
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn with explicit void Promise',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async (): Promise<void> => {
await someOperation()
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn with Promise.resolve(undefined)',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve(undefined),
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn with external void async function',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
async function voidOperation(): Promise<void> {
await someOperation()
}
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: voidOperation,
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn with conditional return (one branch missing)',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => {
if (Math.random() > 0.5) {
return { data: 'test' }
}
// Missing return in the else case
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryFn with ternary operator returning undefined',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: () => Math.random() > 0.5 ? { data: 'test' } : undefined,
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'async queryFn with try/catch missing return in catch',
code: normalizeIndent`
import { useQuery } from '@tanstack/react-query'
function Component() {
const query = useQuery({
queryKey: ['test'],
queryFn: async () => {
try {
return { data: 'test' }
} catch (error) {
console.error(error)
// No return here results in an implicit undefined
}
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'useInfiniteQuery queryFn returns void',
code: normalizeIndent`
import { useInfiniteQuery } from '@tanstack/react-query'
function Component() {
const query = useInfiniteQuery({
queryKey: ['test'],
queryFn: async ({ pageParam }) => {
await fetch('/api/test?page=' + pageParam)
},
initialPageParam: 0,
getNextPageParam: (lastPage) => undefined,
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'useSuspenseQuery queryFn returns void',
code: normalizeIndent`
import { useSuspenseQuery } from '@tanstack/react-query'
function Component() {
const query = useSuspenseQuery({
queryKey: ['test'],
queryFn: () => {
console.log('fetching')
},
})
return null
}
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'queryOptions queryFn returns void',
code: normalizeIndent`
import { queryOptions } from '@tanstack/react-query'
const options = queryOptions({
queryKey: ['test'],
queryFn: async () => {
await fetch('/api/test')
},
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'fetchQuery queryFn returns void',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.fetchQuery({
queryKey: ['test'],
queryFn: async () => {
await fetch('/api/test')
},
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'prefetchQuery queryFn returns void',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.prefetchQuery({
queryKey: ['test'],
queryFn: async () => {
await fetch('/api/test')
},
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'prefetchInfiniteQuery queryFn returns void',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.prefetchInfiniteQuery({
queryKey: ['test'],
queryFn: async ({ pageParam }: { pageParam: number }) => {
await fetch(\`/api/test?page=\${pageParam}\`)
},
initialPageParam: 0,
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'ensureQueryData queryFn returns void',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.ensureQueryData({
queryKey: ['test'],
queryFn: async () => {
await fetch('/api/test')
},
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
{
name: 'ensureInfiniteQueryData queryFn returns void',
code: normalizeIndent`
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
queryClient.ensureInfiniteQueryData({
queryKey: ['test'],
queryFn: async ({ pageParam }: { pageParam: number }) => {
await fetch(\`/api/test?page=\${pageParam}\`)
},
initialPageParam: 0,
})
`,
errors: [{ messageId: 'noVoidReturn' }],
},
],
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { sortDataByOrder } from '../utils/sort-data-by-order'
describe('create-route-property-order utils', () => {
describe('sortDataByOrder', () => {
const testCases = [
{
data: [{ key: 'a' }, { key: 'c' }, { key: 'b' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
},
{
data: [{ key: 'b' }, { key: 'a' }, { key: 'c' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: null,
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'd' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: null,
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'd' }, { key: 'c' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: null,
},
{
data: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: null,
},
{
data: [{ key: 'd' }, { key: 'b' }, { key: 'a' }, { key: 'c' }],
orderArray: [
[['a'], ['b']],
[['b'], ['c']],
],
key: 'key',
expected: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
},
] as const
it.each(testCases)(
'$data $orderArray $key $expected',
({ data, orderArray, key, expected }) => {
const sortedData = sortDataByOrder(data, orderArray, key)
expect(sortedData).toEqual(expected)
},
)
})
})

View File

@@ -0,0 +1,216 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import { rule } from '../rules/stable-query-client/stable-query-client.rule'
import { normalizeIndent } from './test-utils'
const ruleTester = new RuleTester()
ruleTester.run('stable-query-client', rule, {
valid: [
{
name: 'QueryClient is stable when wrapped in React.useState',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = React.useState(() => new QueryClient());
return;
}
`,
},
{
name: 'QueryClient is stable when wrapped in useState',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = useState(() => new QueryClient());
return;
}
`,
},
{
name: 'QueryClient is stable when wrapped in React.useMemo',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = React.useMemo(() => new QueryClient(), []);
return;
}
`,
},
{
name: 'QueryClient is stable when wrapped in useAnything',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = useAnything(() => new QueryClient());
return;
}
`,
},
{
name: 'QueryClient is imported from a non-tanstack package',
code: normalizeIndent`
import { QueryClient } from "other-library";
function Component() {
const queryClient = new QueryClient();
return;
}
`,
},
{
name: 'QueryClient is not imported from @tanstack/react-query',
code: normalizeIndent`
import { QueryClient } from "@tanstack/solid-query";
function Component() {
const queryClient = new QueryClient();
return;
}
`,
},
{
name: 'QueryClient is invoked outside of a function',
code: normalizeIndent`
import { QueryClient } from "@tanstack/solid-query";
const queryClient = new QueryClient();
function Component() {
return;
}
`,
},
{
name: 'QueryClient is invoked in a non-component function',
code: normalizeIndent`
import { QueryClient } from "@tanstack/solid-query";
function someFn() {
const queryClient = new QueryClient();
return;
}
`,
},
{
name: 'QueryClient is not flagged when imported from a non-react-query package in an async component',
code: normalizeIndent`
import { QueryClient } from "@tanstack/solid-query";
async function AsyncComponent() {
const queryClient = new QueryClient();
return;
}
`,
},
{
name: 'QueryClient is not flagged in an async react-query server component',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
async function ServerComponent() {
const queryClient = new QueryClient();
return;
}
`,
},
],
invalid: [
{
name: 'QueryClient is not stable when it is not wrapped in React.useState in component',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const queryClient = new QueryClient();
return;
}
`,
output: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = React.useState(() => new QueryClient());
return;
}
`,
errors: [{ messageId: 'unstable' }],
},
{
name: 'QueryClient is not stable when it is not wrapped in React.useState in custom hook',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function useHook() {
const queryClient = new QueryClient();
return;
}
`,
output: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function useHook() {
const [queryClient] = React.useState(() => new QueryClient());
return;
}
`,
errors: [{ messageId: 'unstable' }],
},
{
name: 'preserve QueryClient options',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const queryClient = new QueryClient({ defaultOptions: { /* */ } });
return;
}
`,
output: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { /* */ } }));
return;
}
`,
errors: [{ messageId: 'unstable' }],
},
{
name: 'preserve QueryClient variable declarator name',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const customName = new QueryClient();
return;
}
`,
output: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const [customName] = React.useState(() => new QueryClient());
return;
}
`,
errors: [{ messageId: 'unstable' }],
},
{
name: 'QueryClient with destructuring pattern reports error without autofix',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
function Component() {
const { defaultOptions } = new QueryClient();
return;
}
`,
output: null,
errors: [{ messageId: 'unstable' }],
},
],
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
expectArrayEqualIgnoreOrder,
generateInterleavedCombinations,
generatePartialCombinations,
generatePermutations,
} from './test-utils'
describe('test-utils', () => {
describe('generatePermutations', () => {
const testCases = [
{
input: ['a', 'b', 'c'],
expected: [
['a', 'b', 'c'],
['a', 'c', 'b'],
['b', 'a', 'c'],
['b', 'c', 'a'],
['c', 'a', 'b'],
['c', 'b', 'a'],
],
},
{
input: ['a', 'b'],
expected: [
['a', 'b'],
['b', 'a'],
],
},
{
input: ['a'],
expected: [['a']],
},
]
it.each(testCases)('$input $expected', ({ input, expected }) => {
const permutations = generatePermutations(input)
expect(permutations).toEqual(expected)
})
})
describe('generatePartialCombinations', () => {
const testCases = [
{
input: ['a', 'b', 'c'],
minLength: 2,
expected: [
['a', 'b'],
['a', 'c'],
['b', 'c'],
['a', 'b', 'c'],
],
},
{
input: ['a', 'b'],
expected: [['a', 'b']],
minLength: 2,
},
{
input: ['a'],
expected: [],
minLength: 2,
},
{
input: ['a'],
expected: [['a']],
minLength: 1,
},
{
input: ['a'],
expected: [[], ['a']],
minLength: 0,
},
]
it.each(testCases)(
'$input $minLength $expected',
({ input, minLength, expected }) => {
const combinations = generatePartialCombinations(input, minLength)
expectArrayEqualIgnoreOrder(combinations, expected)
},
)
})
describe('generateInterleavedCombinations', () => {
const testCases = [
{
data: ['a', 'b'],
additional: ['x'],
expected: [
['a', 'b'],
['x', 'a', 'b'],
['a', 'x', 'b'],
['a', 'b', 'x'],
],
},
]
it.each(testCases)('$input $expected', ({ data, additional, expected }) => {
const combinations = generateInterleavedCombinations(data, additional)
expectArrayEqualIgnoreOrder(combinations, expected)
})
})
})

View File

@@ -0,0 +1,108 @@
import { expect } from 'vitest'
export function normalizeIndent(template: TemplateStringsArray) {
const codeLines = template[0]?.split('\n') ?? ['']
const leftPadding = codeLines[1]?.match(/\s+/)?.[0] ?? ''
return codeLines.map((line) => line.slice(leftPadding.length)).join('\n')
}
export function generatePermutations<T>(arr: Array<T>): Array<Array<T>> {
if (arr.length <= 1) {
return [arr]
}
const result: Array<Array<T>> = []
for (let i = 0; i < arr.length; i++) {
const rest = arr.slice(0, i).concat(arr.slice(i + 1))
const restPermutations = generatePermutations(rest)
for (const perm of restPermutations) {
result.push([arr[i]!, ...perm])
}
}
return result
}
export function generatePartialCombinations<T>(
arr: ReadonlyArray<T>,
minLength: number,
): Array<Array<T>> {
const result: Array<Array<T>> = []
function backtrack(start: number, current: Array<T>) {
if (current.length > minLength - 1) {
result.push([...current])
}
for (let i = start; i < arr.length; i++) {
current.push(arr[i]!)
backtrack(i + 1, current)
current.pop()
}
}
backtrack(0, [])
return result
}
export function expectArrayEqualIgnoreOrder<T>(a: Array<T>, b: Array<T>) {
expect([...a].sort()).toEqual([...b].sort())
}
export function generateInterleavedCombinations<
TData,
TAdditional,
TResult extends TData | TAdditional,
>(
data: Array<TData> | ReadonlyArray<TData>,
additional: Array<TAdditional> | ReadonlyArray<TAdditional>,
): Array<Array<TResult>> {
const result: Array<Array<TResult>> = []
function getSubsets(array: Array<TAdditional>): Array<Array<TAdditional>> {
return array.reduce(
(subsets, value) => {
return subsets.concat(subsets.map((set) => [...set, value]))
},
[[]] as Array<Array<TAdditional>>,
)
}
function insertAtPositions(
baseData: Array<TResult>,
subset: Array<TResult>,
): Array<Array<TResult>> {
const combinations: Array<Array<TResult>> = []
const recurse = (
currentData: Array<TResult>,
currentSubset: Array<TResult>,
start: number,
): void => {
if (currentSubset.length === 0) {
combinations.push([...currentData])
return
}
for (let i = start; i <= currentData.length; i++) {
const newData = [
...currentData.slice(0, i),
currentSubset[0]!,
...currentData.slice(i),
]
recurse(newData, currentSubset.slice(1), i + 1)
}
}
recurse(baseData, subset, 0)
return combinations
}
const subsets = getSubsets(additional as Array<TAdditional>)
subsets.forEach((subset) => {
result.push(
...insertAtPositions(data as Array<TResult>, subset as Array<TResult>),
)
})
return result
}

View File

@@ -0,0 +1 @@
// File needs to exists

View File

@@ -0,0 +1,20 @@
// Ambient stub so type-checked tests can resolve `@tanstack/react-query`
// without adding it as a devDependency of this plugin.
declare module '@tanstack/react-query' {
export type UseQueryResult<TData = unknown> = {
data: TData | undefined
isLoading: boolean
isError: boolean
}
// Declared as an interface so its type resolves via `getSymbol()` rather
// than `aliasSymbol`, exercising the non-alias detection path.
export interface QueryObserverResult<TData = unknown> {
data: TData | undefined
isLoading: boolean
isError: boolean
}
export function useQuery<TData>(options: {
queryKey: ReadonlyArray<unknown>
queryFn: () => Promise<TData>
}): UseQueryResult<TData>
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["**/*"]
}

View File

@@ -0,0 +1,71 @@
import { rules } from './rules'
import type { ESLint, Linter } from 'eslint'
import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'
type RuleKey = keyof typeof rules
export interface Plugin extends Omit<ESLint.Plugin, 'rules'> {
rules: Record<RuleKey, RuleModule<any, any, any>>
configs: {
recommended: ESLint.ConfigData
recommendedStrict: ESLint.ConfigData
'flat/recommended': Array<Linter.Config>
'flat/recommended-strict': Array<Linter.Config>
}
}
const recommendedRules = {
'@tanstack/query/exhaustive-deps': 'error',
'@tanstack/query/no-rest-destructuring': 'warn',
'@tanstack/query/stable-query-client': 'error',
'@tanstack/query/no-unstable-deps': 'error',
'@tanstack/query/infinite-query-property-order': 'error',
'@tanstack/query/no-void-query-fn': 'error',
'@tanstack/query/mutation-property-order': 'error',
} as const
const recommendedStrictRules = {
...recommendedRules,
'@tanstack/query/prefer-query-options': 'error',
} as const
export const plugin = {
meta: {
name: '@tanstack/eslint-plugin-query',
},
configs: {
recommended: {
plugins: ['@tanstack/query'],
rules: recommendedRules,
},
recommendedStrict: {
plugins: ['@tanstack/query'],
rules: recommendedStrictRules,
},
'flat/recommended': [
{
name: 'tanstack/query/flat/recommended',
plugins: {
'@tanstack/query': {}, // Assigned after plugin object created
},
rules: recommendedRules,
},
],
'flat/recommended-strict': [
{
name: 'tanstack/query/flat/recommended-strict',
plugins: {
'@tanstack/query': {}, // Assigned after plugin object created
},
rules: recommendedStrictRules,
},
],
},
rules,
} satisfies Plugin
plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin
plugin.configs['flat/recommended-strict'][0]!.plugins['@tanstack/query'] =
plugin
export default plugin

View File

@@ -0,0 +1,29 @@
import * as exhaustiveDeps from './rules/exhaustive-deps/exhaustive-deps.rule'
import * as stableQueryClient from './rules/stable-query-client/stable-query-client.rule'
import * as noRestDestructuring from './rules/no-rest-destructuring/no-rest-destructuring.rule'
import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule'
import * as infiniteQueryPropertyOrder from './rules/infinite-query-property-order/infinite-query-property-order.rule'
import * as noVoidQueryFn from './rules/no-void-query-fn/no-void-query-fn.rule'
import * as mutationPropertyOrder from './rules/mutation-property-order/mutation-property-order.rule'
import * as preferQueryOptions from './rules/prefer-query-options/prefer-query-options.rule'
import type { ESLintUtils } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from './types'
export const rules: Record<
string,
ESLintUtils.RuleModule<
string,
ReadonlyArray<unknown>,
ExtraRuleDocs,
ESLintUtils.RuleListener
>
> = {
[exhaustiveDeps.name]: exhaustiveDeps.rule,
[stableQueryClient.name]: stableQueryClient.rule,
[noRestDestructuring.name]: noRestDestructuring.rule,
[noUnstableDeps.name]: noUnstableDeps.rule,
[infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule,
[noVoidQueryFn.name]: noVoidQueryFn.rule,
[mutationPropertyOrder.name]: mutationPropertyOrder.rule,
[preferQueryOptions.name]: preferQueryOptions.rule,
}

View File

@@ -0,0 +1,251 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import { ExhaustiveDepsUtils } from './exhaustive-deps.utils'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'
const QUERY_KEY = 'queryKey'
const QUERY_FN = 'queryFn'
export const name = 'exhaustive-deps'
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
type RuleOption = {
allowlist?: {
variables?: Array<string>
types?: Array<string>
}
}
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description: 'Exhaustive deps rule for useQuery',
recommended: 'error',
},
messages: {
missingDeps: `The following dependencies are missing in your queryKey: {{deps}}`,
fixTo: 'Fix to {{result}}',
},
hasSuggestions: true,
fixable: 'code',
schema: [
{
type: 'object',
properties: {
allowlist: {
type: 'object',
properties: {
variables: { type: 'array', items: { type: 'string' } },
types: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
},
defaultOptions: [],
create: detectTanstackQueryImports((context) => {
return {
ObjectExpression: (node: TSESTree.ObjectExpression) => {
const scopeManager = context.sourceCode.scopeManager
const queryKey = ASTUtils.findPropertyWithIdentifierKey(
node.properties,
QUERY_KEY,
)
const queryFn = ASTUtils.findPropertyWithIdentifierKey(
node.properties,
QUERY_FN,
)
if (
scopeManager === null ||
queryKey === undefined ||
queryFn === undefined ||
!ASTUtils.isNodeOfOneOf(queryFn.value, [
AST_NODE_TYPES.ArrowFunctionExpression,
AST_NODE_TYPES.FunctionExpression,
AST_NODE_TYPES.ConditionalExpression,
])
) {
return
}
const queryKeyNode = dereferenceVariablesAndTypeAssertions(
queryKey.value,
context,
)
const queryFnNodes = ExhaustiveDepsUtils.getQueryFnNodes(queryFn)
const externalRefs = queryFnNodes.flatMap((fnNode) =>
ASTUtils.getExternalRefs({
scopeManager,
sourceCode: context.sourceCode,
node: fnNode,
}),
)
const relevantRefs = externalRefs.filter((reference) =>
queryFnNodes.some((fnNode) =>
ExhaustiveDepsUtils.isRelevantReference({
sourceCode: context.sourceCode,
reference,
scopeManager,
node: fnNode,
filename: context.filename,
}),
),
)
const ruleOptions = context.options.at(0) as RuleOption | undefined
const allowlistedVariables = new Set(
ruleOptions?.allowlist?.variables ?? [],
)
const allowlistedTypes = new Set(ruleOptions?.allowlist?.types ?? [])
const requiredRefs = relevantRefs.flatMap((ref) => {
if (ref.identifier.type !== AST_NODE_TYPES.Identifier) return []
const refPath = ExhaustiveDepsUtils.computeRefPath({
identifier: ref.identifier,
sourceCode: context.sourceCode,
})
if (refPath === null) return []
return [
{
...refPath,
allowlistedByType:
ExhaustiveDepsUtils.variableIsAllowlistedByType({
allowlistedTypes,
variable: ref.resolved ?? null,
}),
},
]
})
if (requiredRefs.length === 0) return
const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({
sourceCode: context.sourceCode,
scopeManager: scopeManager,
queryKeyNode: queryKeyNode,
})
const missingPaths = ExhaustiveDepsUtils.computeFilteredMissingPaths({
requiredRefs: requiredRefs,
allowlistedVariables: allowlistedVariables,
existingRootIdentifiers: queryKeyDeps.roots,
existingFullPaths: queryKeyDeps.paths,
})
if (missingPaths.length === 0) return
const missingAsText = missingPaths.join(', ')
const suggestions = buildSuggestions({
queryKeyNode,
missingPaths,
missingAsText,
sourceCode: context.sourceCode,
})
context.report({
node,
messageId: 'missingDeps',
data: { deps: missingAsText },
suggest: suggestions,
})
},
}
}),
})
function buildSuggestions(params: {
queryKeyNode: TSESTree.Node
missingPaths: Array<string>
missingAsText: string
sourceCode: Readonly<TSESLint.SourceCode>
}): TSESLint.ReportSuggestionArray<string> {
const { queryKeyNode, missingPaths, missingAsText, sourceCode } = params
if (queryKeyNode.type !== AST_NODE_TYPES.ArrayExpression) {
return []
}
const closingBracket = sourceCode.getLastToken(queryKeyNode)
if (!closingBracket) return []
const existingElements = queryKeyNode.elements
.filter((el): el is NonNullable<typeof el> => el !== null)
.map((el) => sourceCode.getText(el))
const resultText = `[${[...existingElements, ...missingPaths].join(', ')}]`
if (queryKeyNode.elements.length === 0) {
return [
{
messageId: 'fixTo',
data: { result: resultText },
fix: (fixer) => fixer.replaceText(queryKeyNode, resultText),
},
]
}
const tokenBefore = sourceCode.getTokenBefore(closingBracket)
const separator = tokenBefore?.value === ',' ? ' ' : ', '
return [
{
messageId: 'fixTo',
data: { result: resultText },
fix: (fixer) =>
fixer.insertTextBefore(closingBracket, `${separator}${missingAsText}`),
},
]
}
function dereferenceVariablesAndTypeAssertions(
queryKeyNode: TSESTree.Node,
context: Readonly<TSESLint.RuleContext<string, ReadonlyArray<unknown>>>,
) {
const visitedNodes = new Set<TSESTree.Node>()
for (let i = 0; i < 1 << 8; ++i) {
if (visitedNodes.has(queryKeyNode)) {
return queryKeyNode
}
visitedNodes.add(queryKeyNode)
switch (queryKeyNode.type) {
case AST_NODE_TYPES.TSAsExpression:
queryKeyNode = queryKeyNode.expression
break
case AST_NODE_TYPES.Identifier: {
const expression = ASTUtils.getReferencedExpressionByIdentifier({
context,
node: queryKeyNode,
})
if (expression == null) {
return queryKeyNode
}
queryKeyNode = expression
break
}
default:
return queryKeyNode
}
}
return queryKeyNode
}

View File

@@ -0,0 +1,448 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
export const ExhaustiveDepsUtils = {
isRelevantReference(params: {
sourceCode: Readonly<TSESLint.SourceCode>
reference: TSESLint.Scope.Reference
scopeManager: TSESLint.Scope.ScopeManager
node: TSESTree.Node
filename: string
}) {
const { sourceCode, reference, scopeManager, node, filename } = params
const component = ASTUtils.getFunctionAncestor(sourceCode, node)
const queryFnScope = scopeManager.acquire(node)
if (queryFnScope === null || reference.isValueReference === false) {
return false
}
let currentScope = reference.resolved?.scope ?? null
while (currentScope !== null) {
if (currentScope === queryFnScope) {
return false
}
currentScope = currentScope.upper
}
if (component !== undefined) {
if (
!ASTUtils.isDeclaredInNode({
scopeManager,
reference,
functionNode: component,
})
) {
return false
}
} else {
const isVueFile = filename.endsWith('.vue')
if (!isVueFile) {
return false
}
const definition = reference.resolved?.defs[0]
const isGlobalVariable = definition === undefined
const isImport = definition?.type === 'ImportBinding'
if (isGlobalVariable || isImport) {
return false
}
}
return (
reference.identifier.name !== 'undefined' &&
!ExhaustiveDepsUtils.isFunctionCallTarget(reference.identifier) &&
reference.identifier.parent.type !== AST_NODE_TYPES.NewExpression &&
!ExhaustiveDepsUtils.isInstanceOfKind(reference.identifier.parent)
)
},
isFunctionCallTarget(
identifier: TSESTree.Identifier | TSESTree.JSXIdentifier,
): boolean {
const callee = ASTUtils.traverseUpMemberExpression(identifier)
return (
callee.parent !== undefined &&
callee.parent.type === AST_NODE_TYPES.CallExpression &&
callee.parent.callee === callee
)
},
/**
* Given required refs and existing queryKey entries, compute missing dependency paths
* respecting allowlisted variables and types.
*/
computeFilteredMissingPaths(params: {
requiredRefs: Array<{
path: string
root: string
allowlistedByType: boolean
}>
allowlistedVariables: Set<string>
existingRootIdentifiers: Set<string>
existingFullPaths: Set<string>
}): Array<string> {
const {
requiredRefs,
allowlistedVariables,
existingRootIdentifiers,
existingFullPaths,
} = params
const missingPaths = new Set<string>()
for (const { root, path, allowlistedByType } of requiredRefs) {
// If root itself is present in the key, it covers all members
if (existingRootIdentifiers.has(root)) continue
if (allowlistedVariables.has(root)) continue
if (existingFullPaths.has(path)) continue
if (allowlistedByType) continue
missingPaths.add(path)
}
// Collapse descendants: if a root is already missing, drop deeper paths
for (const path of missingPaths) {
const root = path.split('.')[0]
if (root !== path && root !== undefined && missingPaths.has(root)) {
missingPaths.delete(path)
}
}
return Array.from(missingPaths)
},
/**
* Extract existing queryKey deps as root identifiers and full member paths.
*/
collectQueryKeyDeps(params: {
sourceCode: Readonly<TSESLint.SourceCode>
scopeManager: TSESLint.Scope.ScopeManager
queryKeyNode: TSESTree.Node
}): { roots: Set<string>; paths: Set<string> } {
const { sourceCode, scopeManager, queryKeyNode } = params
const roots = new Set<string>()
const paths = new Set<string>()
const visitorKeys = sourceCode.visitorKeys
function addRoot(name: string) {
const cleaned = ExhaustiveDepsUtils.normalizeChain(name)
roots.add(cleaned)
paths.add(cleaned)
}
function addFull(text: string) {
const cleaned = ExhaustiveDepsUtils.normalizeChain(text)
paths.add(cleaned)
}
function addRefPath(
refPath: {
path: string
root: string
coversRootMembers: boolean
} | null,
) {
if (!refPath) return
if (refPath.coversRootMembers) {
addRoot(refPath.root)
return
}
addFull(refPath.path)
}
function visitChildren(node: TSESTree.Node): void {
const keys = (visitorKeys[node.type] ?? []) as ReadonlyArray<
keyof TSESTree.Node
>
for (const key of keys) {
const value = node[key]
if (Array.isArray(value)) {
for (const item of value) {
if (ExhaustiveDepsUtils.isNode(item)) {
visit(item)
}
}
continue
}
if (ExhaustiveDepsUtils.isNode(value)) {
visit(value)
}
}
}
function visit(node: TSESTree.Node | null | undefined): void {
if (!node) return
switch (node.type) {
case AST_NODE_TYPES.Identifier: {
addRefPath(
ExhaustiveDepsUtils.computeRefPath({
identifier: node,
sourceCode: sourceCode,
}),
)
return
}
case AST_NODE_TYPES.ArrowFunctionExpression:
case AST_NODE_TYPES.FunctionExpression:
for (const reference of ExhaustiveDepsUtils.collectExternalRefsInFunction(
{
functionNode: node,
scopeManager: scopeManager,
},
)) {
if (reference.identifier.type !== AST_NODE_TYPES.Identifier) {
continue
}
addRefPath(
ExhaustiveDepsUtils.computeRefPath({
identifier: reference.identifier,
sourceCode: sourceCode,
}),
)
}
return
case AST_NODE_TYPES.Property:
visit(node.value)
return
case AST_NODE_TYPES.MemberExpression:
if (
node.parent.type === AST_NODE_TYPES.CallExpression &&
node.parent.callee === node &&
node.object.type === AST_NODE_TYPES.Identifier
) {
addRoot(node.object.name)
} else {
visit(node.object)
}
return
case AST_NODE_TYPES.CallExpression:
node.arguments.forEach((argument) => visit(argument))
switch (node.callee.type) {
case AST_NODE_TYPES.Identifier:
case AST_NODE_TYPES.MemberExpression:
case AST_NODE_TYPES.ChainExpression:
case AST_NODE_TYPES.TSNonNullExpression:
visit(node.callee)
break
}
return
}
visitChildren(node)
}
visit(queryKeyNode)
return { roots, paths }
},
isNode(value: unknown): value is TSESTree.Node {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string'
)
},
/**
* Checks whether the resolved variable is allowlisted by its type annotation
*/
variableIsAllowlistedByType(params: {
allowlistedTypes: Set<string>
variable: TSESLint.Scope.Variable | null
}): boolean {
const { allowlistedTypes, variable } = params
if (allowlistedTypes.size === 0) return false
if (!variable) return false
for (const id of variable.identifiers) {
if (id.typeAnnotation) {
const typeIdentifiers = new Set<string>()
ExhaustiveDepsUtils.collectTypeIdentifiers(
id.typeAnnotation.typeAnnotation,
typeIdentifiers,
)
for (const typeIdentifier of typeIdentifiers) {
if (allowlistedTypes.has(typeIdentifier)) return true
}
}
}
return false
},
isInstanceOfKind(node: TSESTree.Node) {
return (
node.type === AST_NODE_TYPES.BinaryExpression &&
(node as TSESTree.SymmetricBinaryExpression).operator === 'instanceof'
)
},
/**
* Normalizes a chain by removing optional chaining operators
*
* Example: `a?.b.c!` -> `a.b.c`
*/
normalizeChain(text: string): string {
return text.replace(/(?:\?(\.)|!)/g, '$1').replace(/\s+/g, '')
},
/**
* Computes the reference path for an identifier
*
* Example: `a.b.c!` -> `{ path: 'a.b.c', root: 'a' }`
*/
computeRefPath(params: {
identifier: TSESTree.Identifier
sourceCode: Readonly<TSESLint.SourceCode>
}): { path: string; root: string; coversRootMembers: boolean } | null {
const { identifier, sourceCode } = params
const fullChainNode = ASTUtils.traverseUpMemberExpression(identifier)
const fullText = ExhaustiveDepsUtils.normalizeChain(
sourceCode.getText(fullChainNode),
)
const parent = fullChainNode.parent
let dependencyPath = fullText
let coversRootMembers = fullText === identifier.name
if (
parent &&
parent.type === AST_NODE_TYPES.CallExpression &&
parent.callee === fullChainNode
) {
const segments = fullText.split('.')
if (segments.length > 1) {
dependencyPath = segments.slice(0, -1).join('.')
}
coversRootMembers = false
}
dependencyPath =
dependencyPath.split('.')[0] === '' ? identifier.name : dependencyPath
const root = dependencyPath.split('.')[0]
return {
path: dependencyPath,
root: root ?? identifier.name,
coversRootMembers: coversRootMembers && dependencyPath === root,
}
},
collectExternalRefsInFunction(params: {
functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression
scopeManager: TSESLint.Scope.ScopeManager
}): Array<TSESLint.Scope.Reference> {
const { functionNode, scopeManager } = params
const functionScope = scopeManager.acquire(functionNode)
if (functionScope === null) {
return []
}
const externalRefs: Array<TSESLint.Scope.Reference> = []
function collect(scope: TSESLint.Scope.Scope) {
for (const reference of scope.references) {
if (!reference.isRead() || reference.resolved === null) {
continue
}
let currentScope: TSESLint.Scope.Scope | null = reference.resolved.scope
let declaredInsideFunction = false
while (currentScope !== null) {
if (currentScope === functionScope) {
declaredInsideFunction = true
break
}
currentScope = currentScope.upper
}
if (!declaredInsideFunction) {
externalRefs.push(reference)
}
}
for (const childScope of scope.childScopes) {
collect(childScope)
}
}
collect(functionScope)
return externalRefs
},
/**
* Recursively collects type identifiers from a type annotation
*/
collectTypeIdentifiers(typeNode: TSESTree.TypeNode, out: Set<string>): void {
switch (typeNode.type) {
case AST_NODE_TYPES.TSTypeReference: {
if (typeNode.typeName.type === AST_NODE_TYPES.Identifier) {
out.add(typeNode.typeName.name)
}
break
}
case AST_NODE_TYPES.TSUnionType:
case AST_NODE_TYPES.TSIntersectionType: {
typeNode.types.forEach((t) =>
ExhaustiveDepsUtils.collectTypeIdentifiers(t, out),
)
break
}
case AST_NODE_TYPES.TSArrayType: {
ExhaustiveDepsUtils.collectTypeIdentifiers(typeNode.elementType, out)
break
}
case AST_NODE_TYPES.TSTupleType: {
typeNode.elementTypes.forEach((et) =>
ExhaustiveDepsUtils.collectTypeIdentifiers(et, out),
)
break
}
}
},
/**
* Gets the function expression nodes from a queryFn property, handling conditional expressions.
* When neither branch is skipToken, returns both branches so all deps are scanned.
*/
getQueryFnNodes(queryFn: TSESTree.Property): Array<TSESTree.Node> {
if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) {
return [queryFn.value]
}
if (
queryFn.value.consequent.type === AST_NODE_TYPES.Identifier &&
queryFn.value.consequent.name === 'skipToken'
) {
return [queryFn.value.alternate]
}
if (
queryFn.value.alternate.type === AST_NODE_TYPES.Identifier &&
queryFn.value.alternate.name === 'skipToken'
) {
return [queryFn.value.consequent]
}
return [queryFn.value.consequent, queryFn.value.alternate]
},
}

View File

@@ -0,0 +1,19 @@
export const infiniteQueryFunctions = [
'infiniteQueryOptions',
'useInfiniteQuery',
'useSuspenseInfiniteQuery',
] as const
export type InfiniteQueryFunctions = (typeof infiniteQueryFunctions)[number]
export const checkedProperties = [
'queryFn',
'getPreviousPageParam',
'getNextPageParam',
] as const
export type InfiniteQueryProperties = (typeof checkedProperties)[number]
export const sortRules = [
[['queryFn'], ['getPreviousPageParam', 'getNextPageParam']],
] as const

View File

@@ -0,0 +1,34 @@
import { createPropertyOrderRule } from '../../utils/create-property-order-rule'
import { infiniteQueryFunctions, sortRules } from './constants'
import type {
InfiniteQueryFunctions,
InfiniteQueryProperties,
} from './constants'
export const name = 'infinite-query-property-order'
export const rule = createPropertyOrderRule<
InfiniteQueryFunctions,
InfiniteQueryProperties
>(
{
name,
meta: {
type: 'problem',
docs: {
description:
'Ensure correct order of inference sensitive properties for infinite queries',
recommended: 'error',
},
messages: {
invalidOrder: 'Invalid order of properties for `{{function}}`.',
},
schema: [],
hasSuggestions: true,
fixable: 'code',
},
defaultOptions: [],
},
infiniteQueryFunctions,
sortRules,
)

View File

@@ -0,0 +1,9 @@
export const mutationFunctions = ['useMutation'] as const
export type MutationFunctions = (typeof mutationFunctions)[number]
export const checkedProperties = ['onMutate', 'onError', 'onSettled'] as const
export type MutationProperties = (typeof checkedProperties)[number]
export const sortRules = [[['onMutate'], ['onError', 'onSettled']]] as const

View File

@@ -0,0 +1,31 @@
import { createPropertyOrderRule } from '../../utils/create-property-order-rule'
import { mutationFunctions, sortRules } from './constants'
import type { MutationFunctions, MutationProperties } from './constants'
export const name = 'mutation-property-order'
export const rule = createPropertyOrderRule<
MutationFunctions,
MutationProperties
>(
{
name,
meta: {
type: 'problem',
docs: {
description:
'Ensure correct order of inference-sensitive properties in useMutation()',
recommended: 'error',
},
messages: {
invalidOrder: 'Invalid order of properties for `{{function}}`.',
},
schema: [],
hasSuggestions: true,
fixable: 'code',
},
defaultOptions: [],
},
mutationFunctions,
sortRules,
)

View File

@@ -0,0 +1,138 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { ASTUtils } from '../../utils/ast-utils'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import { NoRestDestructuringUtils } from './no-rest-destructuring.utils'
import type { ExtraRuleDocs } from '../../types'
export const name = 'no-rest-destructuring'
const queryHooks = [
'useQuery',
'useQueries',
'useInfiniteQuery',
'useSuspenseQuery',
'useSuspenseQueries',
'useSuspenseInfiniteQuery',
]
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description: 'Disallows rest destructuring in queries',
recommended: 'warn',
},
messages: {
objectRestDestructure: `Object rest destructuring on a query will observe all changes to the query, leading to excessive re-renders.`,
},
schema: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context, _, helpers) => {
const queryResultVariables = new Set<string>()
return {
CallExpression: (node) => {
if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) {
return
}
const returnValue = node.parent.id
const isDirectHook =
ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks) &&
helpers.isTanstackQueryImport(node.callee)
if (!isDirectHook) {
// The type-aware path can only report when the result is rest
// destructured or assigned to an identifier that may later be
// spread. Skip the expensive type lookup for any other binding.
const canReportQueryResult =
returnValue.type === AST_NODE_TYPES.Identifier ||
NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)
if (
!canReportQueryResult ||
!NoRestDestructuringUtils.isQueryResultCall(
node,
context.sourceCode.parserServices,
)
) {
return
}
}
const calleeName = ASTUtils.isIdentifier(node.callee)
? node.callee.name
: null
if (
calleeName !== 'useQueries' &&
calleeName !== 'useSuspenseQueries'
) {
if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
return context.report({
node: node.parent,
messageId: 'objectRestDestructure',
})
}
if (returnValue.type === AST_NODE_TYPES.Identifier) {
queryResultVariables.add(returnValue.name)
}
return
}
if (returnValue.type !== AST_NODE_TYPES.ArrayPattern) {
if (returnValue.type === AST_NODE_TYPES.Identifier) {
queryResultVariables.add(returnValue.name)
}
return
}
returnValue.elements.forEach((queryResult) => {
if (queryResult === null) {
return
}
if (NoRestDestructuringUtils.isObjectRestDestructuring(queryResult)) {
context.report({
node: queryResult,
messageId: 'objectRestDestructure',
})
}
})
},
VariableDeclarator: (node) => {
if (
node.init?.type === AST_NODE_TYPES.Identifier &&
queryResultVariables.has(node.init.name) &&
NoRestDestructuringUtils.isObjectRestDestructuring(node.id)
) {
context.report({
node,
messageId: 'objectRestDestructure',
})
}
},
SpreadElement: (node) => {
if (
node.argument.type === AST_NODE_TYPES.Identifier &&
queryResultVariables.has(node.argument.name)
) {
context.report({
node,
messageId: 'objectRestDestructure',
})
}
},
}
}),
})

View File

@@ -0,0 +1,55 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import type {
ParserServices,
ParserServicesWithTypeInformation,
TSESTree,
} from '@typescript-eslint/utils'
type TypeChecker = ReturnType<
ParserServicesWithTypeInformation['program']['getTypeChecker']
>
type Type = ReturnType<TypeChecker['getTypeAtLocation']>
const QUERY_RESULT_TYPE_NAMES = new Set([
'UseBaseQueryResult',
'UseQueryResult',
'UseSuspenseQueryResult',
'DefinedUseQueryResult',
'UseInfiniteQueryResult',
'UseSuspenseInfiniteQueryResult',
'DefinedUseInfiniteQueryResult',
'QueryObserverResult',
'InfiniteQueryObserverResult',
])
function isQueryResultType(type: Type): boolean {
if (type.aliasSymbol && QUERY_RESULT_TYPE_NAMES.has(type.aliasSymbol.name)) {
return true
}
const symbol = type.getSymbol()
if (symbol && QUERY_RESULT_TYPE_NAMES.has(symbol.name)) {
return true
}
return type.isUnion() && type.types.some(isQueryResultType)
}
export const NoRestDestructuringUtils = {
isObjectRestDestructuring(node: TSESTree.Node): boolean {
if (node.type !== AST_NODE_TYPES.ObjectPattern) {
return false
}
return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement)
},
isQueryResultCall(
node: TSESTree.CallExpression,
parserServices: Partial<ParserServices> | null | undefined,
): boolean {
if (!parserServices?.program || !parserServices.esTreeNodeToTSNodeMap) {
return false
}
const checker = parserServices.program.getTypeChecker()
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.callee)
const signatures = checker.getTypeAtLocation(tsNode).getCallSignatures()
return signatures.some((sig) => isQueryResultType(sig.getReturnType()))
},
}

View File

@@ -0,0 +1,283 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import type { TSESTree } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'
export const name = 'no-unstable-deps'
export const reactHookNames = ['useEffect', 'useCallback', 'useMemo']
export const useQueryHookNames = [
'useQuery',
'useSuspenseQuery',
'useQueries',
'useSuspenseQueries',
'useInfiniteQuery',
'useSuspenseInfiniteQuery',
]
const allHookNames = ['useMutation', ...useQueryHookNames]
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description:
'Disallow putting the result of query hooks directly in a React hook dependency array',
recommended: 'error',
},
messages: {
noUnstableDeps: `The result of {{queryHook}} is not referentially stable, so don't pass it directly into the dependencies array of {{reactHook}}. Instead, destructure the return value of {{queryHook}} and pass the destructured values into the dependency array of {{reactHook}}.`,
},
schema: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context, _options, helpers) => {
const trackedVariables: Record<string, string> = Object.create(null)
const trackedCustomHooks: Record<string, string> = Object.create(null)
const hookAliasMap: Record<string, string> = Object.create(null)
const pendingVariableDeclarators: Array<TSESTree.VariableDeclarator> = []
const pendingDependencyChecks: Array<{
reactHook: string
depsArray: TSESTree.ArrayExpression
}> = []
function getReactHook(node: TSESTree.CallExpression): string | undefined {
if (node.callee.type === 'Identifier') {
const calleeName = node.callee.name
// Check if the identifier is a known React hook or an alias
if (reactHookNames.includes(calleeName) || calleeName in hookAliasMap) {
return calleeName
}
} else if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'React' &&
node.callee.property.type === 'Identifier' &&
reactHookNames.includes(node.callee.property.name)
) {
// Member expression case: `React.useCallback`
return node.callee.property.name
}
return undefined
}
function collectVariableNames(
pattern: TSESTree.BindingName,
queryHook: string,
) {
if (pattern.type === AST_NODE_TYPES.Identifier) {
trackedVariables[pattern.name] = queryHook
} else if (pattern.type === AST_NODE_TYPES.ArrayPattern) {
for (const element of pattern.elements) {
if (element === null) {
continue
}
if (element.type === AST_NODE_TYPES.Identifier) {
trackedVariables[element.name] = queryHook
} else if (
element.type === AST_NODE_TYPES.RestElement &&
element.argument.type === AST_NODE_TYPES.Identifier
) {
trackedVariables[element.argument.name] = queryHook
}
}
}
}
function isCustomHookName(hookName: string): boolean {
return /^use[A-Z0-9]/.test(hookName)
}
function hasCombineProperty(
callExpression: TSESTree.CallExpression,
): boolean {
if (callExpression.arguments.length === 0) return false
const firstArg = callExpression.arguments[0]
if (!firstArg || firstArg.type !== AST_NODE_TYPES.ObjectExpression)
return false
return firstArg.properties.some(
(prop) =>
prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier &&
prop.key.name === 'combine',
)
}
function getDirectQueryHook(
callExpression: TSESTree.CallExpression,
): string | undefined {
if (
callExpression.callee.type !== AST_NODE_TYPES.Identifier ||
!allHookNames.includes(callExpression.callee.name) ||
!helpers.isTanstackQueryImport(callExpression.callee)
) {
return undefined
}
if (
(callExpression.callee.name === 'useQueries' ||
callExpression.callee.name === 'useSuspenseQueries') &&
hasCombineProperty(callExpression)
) {
return undefined
}
return callExpression.callee.name
}
function getTrackedQueryHook(
callExpression: TSESTree.CallExpression,
): string | undefined {
const directQueryHook = getDirectQueryHook(callExpression)
if (directQueryHook !== undefined) {
return directQueryHook
}
if (callExpression.callee.type === AST_NODE_TYPES.Identifier) {
return trackedCustomHooks[callExpression.callee.name]
}
return undefined
}
function getReturnedQueryHook(
body:
| TSESTree.FunctionExpression['body']
| TSESTree.ArrowFunctionExpression['body'],
): string | undefined {
if (body.type === AST_NODE_TYPES.CallExpression) {
return getDirectQueryHook(body)
}
if (body.type !== AST_NODE_TYPES.BlockStatement) {
return undefined
}
const returnStatements = body.body.filter(
(statement): statement is TSESTree.ReturnStatement =>
statement.type === AST_NODE_TYPES.ReturnStatement,
)
if (returnStatements.length !== 1) {
return undefined
}
const returnArgument = returnStatements[0]?.argument
if (returnArgument?.type === AST_NODE_TYPES.CallExpression) {
return getDirectQueryHook(returnArgument)
}
return undefined
}
function checkDependencyArray(
reactHook: string,
depsArray: TSESTree.ArrayExpression,
) {
depsArray.elements.forEach((dep) => {
if (
dep !== null &&
dep.type === AST_NODE_TYPES.Identifier &&
trackedVariables[dep.name] !== undefined
) {
const queryHook = trackedVariables[dep.name]
context.report({
node: dep,
messageId: 'noUnstableDeps',
data: {
queryHook,
reactHook,
},
})
}
})
}
return {
ImportDeclaration(node: TSESTree.ImportDeclaration) {
if (
node.specifiers.length > 0 &&
node.importKind === 'value' &&
node.source.value === 'React'
) {
node.specifiers.forEach((specifier) => {
if (
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
specifier.imported.type === AST_NODE_TYPES.Identifier &&
reactHookNames.includes(specifier.imported.name)
) {
// Track alias or direct import
hookAliasMap[specifier.local.name] = specifier.imported.name
}
})
}
},
FunctionDeclaration(node) {
if (node.id === null || !isCustomHookName(node.id.name)) {
return
}
const queryHook = getReturnedQueryHook(node.body)
if (queryHook !== undefined) {
trackedCustomHooks[node.id.name] = queryHook
}
},
VariableDeclarator(node) {
if (
node.id.type === AST_NODE_TYPES.Identifier &&
isCustomHookName(node.id.name) &&
node.init !== null &&
(node.init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.init.type === AST_NODE_TYPES.FunctionExpression)
) {
const queryHook = getReturnedQueryHook(node.init.body)
if (queryHook !== undefined) {
trackedCustomHooks[node.id.name] = queryHook
}
}
if (
node.init !== null &&
node.init.type === AST_NODE_TYPES.CallExpression
) {
pendingVariableDeclarators.push(node)
}
},
CallExpression: (node) => {
const reactHook = getReactHook(node)
if (
reactHook !== undefined &&
node.arguments.length > 1 &&
node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression
) {
pendingDependencyChecks.push({
reactHook,
depsArray: node.arguments[1],
})
}
},
'Program:exit'() {
pendingVariableDeclarators.forEach((node) => {
if (node.init?.type !== AST_NODE_TYPES.CallExpression) {
return
}
const queryHook = getTrackedQueryHook(node.init)
if (queryHook !== undefined) {
collectVariableNames(node.id, queryHook)
}
})
pendingDependencyChecks.forEach(({ reactHook, depsArray }) => {
checkDependencyArray(reactHook, depsArray)
})
},
}
}),
})

View File

@@ -0,0 +1,87 @@
import { ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import { getDocsUrl } from '../../utils/get-docs-url'
import type { ParserServicesWithTypeInformation } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'
export const name = 'no-void-query-fn'
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description: 'Ensures queryFn returns a non-undefined value',
recommended: 'error',
},
messages: {
noVoidReturn: 'queryFn must return a non-undefined value',
},
schema: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context) => {
return {
Property(node) {
if (
!ASTUtils.isObjectExpression(node.parent) ||
!ASTUtils.isIdentifierWithName(node.key, 'queryFn')
) {
return
}
const parserServices = context.sourceCode.parserServices
if (
!parserServices ||
!parserServices.esTreeNodeToTSNodeMap ||
!parserServices.program
) {
return
}
const checker = parserServices.program.getTypeChecker()
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.value)
const type = checker.getTypeAtLocation(tsNode)
// Get the return type of the function
if (type.getCallSignatures().length > 0) {
const returnType = type.getCallSignatures()[0]?.getReturnType()
if (!returnType) {
return
}
// Check if return type is void or undefined
if (isIllegalReturn(checker, returnType)) {
context.report({
node: node.value,
messageId: 'noVoidReturn',
})
}
}
},
}
}),
})
type Program = ParserServicesWithTypeInformation['program']
type TypeChecker = ReturnType<Program['getTypeChecker']>
type Type = ReturnType<TypeChecker['getTypeAtLocation']>
function isIllegalReturn(checker: TypeChecker, type: Type): boolean {
const awaited = checker.getAwaitedType(type)
if (!awaited) return false
if (awaited.isUnion()) {
return awaited.types.some((t) => isIllegalReturn(checker, t))
}
const typeString = checker.typeToString(awaited)
return typeString === 'void' || typeString === 'undefined'
}

View File

@@ -0,0 +1,389 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import { getDocsUrl } from '../../utils/get-docs-url'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'
export const name = 'prefer-query-options'
const queryHooks = [
'useQuery',
'useInfiniteQuery',
'useSuspenseQuery',
'useSuspenseInfiniteQuery',
'usePrefetchQuery',
'usePrefetchInfiniteQuery',
]
const queriesHooks = ['useQueries', 'useSuspenseQueries']
const filterHooks = ['useIsFetching']
const queryClientOptionMethods = [
'fetchQuery',
'prefetchQuery',
'fetchInfiniteQuery',
'prefetchInfiniteQuery',
'ensureQueryData',
'ensureInfiniteQueryData',
]
const queryClientQueryKeyMethods = [
'getQueryData',
'setQueryData',
'getQueryState',
'setQueryDefaults',
'getQueryDefaults',
]
const queryClientFilterMethods = [
'invalidateQueries',
'cancelQueries',
'refetchQueries',
'removeQueries',
'resetQueries',
'isFetching',
'getQueriesData',
'setQueriesData',
]
const queryOptionsBuilders = ['queryOptions', 'infiniteQueryOptions']
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
type Helpers = {
isTanstackQueryImport: (node: TSESTree.Identifier) => boolean
}
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description:
'Prefer using queryOptions() to co-locate queryKey and queryFn',
recommended: 'strict',
},
messages: {
preferQueryOptions:
'Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn.',
preferQueryOptionsQueryKey:
'Prefer referencing a queryKey from a queryOptions() result instead of typing it manually.',
},
schema: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context, _, helpers) => {
function reportInlineQueryOptions(node: TSESTree.Node): void {
if (ASTUtils.isObjectExpression(node) && hasInlineQueryOptions(node)) {
context.report({
node,
messageId: 'preferQueryOptions',
})
}
}
function reportInlineFilterQueryKey(node: TSESTree.Node): void {
if (ASTUtils.isObjectExpression(node) && hasInlineFilterQueryKey(node)) {
context.report({
node,
messageId: 'preferQueryOptionsQueryKey',
})
}
}
return {
CallExpression: (node) => {
if (ASTUtils.isIdentifier(node.callee)) {
const importedName = getTanstackImportName(
context,
helpers,
node.callee,
)
if (importedName === null) {
return
}
if (queryOptionsBuilders.includes(importedName)) {
return
}
const options = node.arguments[0]
if (options === undefined) {
return
}
if (queryHooks.includes(importedName)) {
reportInlineQueryOptions(options)
return
}
if (
queriesHooks.includes(importedName) &&
ASTUtils.isObjectExpression(options)
) {
const queries = ASTUtils.findPropertyWithIdentifierKey(
options.properties,
'queries',
)?.value
if (queries !== undefined) {
getQueryObjects(queries).forEach((query) => {
reportInlineQueryOptions(query)
})
}
return
}
if (filterHooks.includes(importedName)) {
reportInlineFilterQueryKey(options)
}
return
}
if (
node.callee.type !== AST_NODE_TYPES.MemberExpression ||
!ASTUtils.isIdentifier(node.callee.property) ||
!isTanstackQueryClient(node.callee.object, context, helpers)
) {
return
}
const method = node.callee.property.name
const options = node.arguments[0]
if (options === undefined) {
return
}
if (queryClientOptionMethods.includes(method)) {
reportInlineQueryOptions(options)
return
}
if (
queryClientQueryKeyMethods.includes(method) &&
isInlineArrayExpression(options)
) {
context.report({
node: options,
messageId: 'preferQueryOptionsQueryKey',
})
return
}
if (queryClientFilterMethods.includes(method)) {
reportInlineFilterQueryKey(options)
}
},
}
}),
})
function hasInlineQueryOptions(node: TSESTree.ObjectExpression): boolean {
return (
ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryKey') !==
undefined ||
ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryFn') !==
undefined
)
}
function hasInlineFilterQueryKey(node: TSESTree.ObjectExpression): boolean {
const queryKey = ASTUtils.findPropertyWithIdentifierKey(
node.properties,
'queryKey',
)?.value
return queryKey !== undefined && isInlineArrayExpression(queryKey)
}
function isInlineArrayExpression(node: TSESTree.Node): boolean {
return unwrapTypeAssertions(node).type === AST_NODE_TYPES.ArrayExpression
}
function getReturnedObjectExpressions(
node: TSESTree.Node,
): Array<TSESTree.ObjectExpression> {
if (ASTUtils.isObjectExpression(node)) {
return [node]
}
if (
node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.type === AST_NODE_TYPES.FunctionExpression
) {
return getReturnedObjectExpressions(node.body)
}
if (node.type === AST_NODE_TYPES.BlockStatement) {
return node.body.flatMap((statement) => {
if (
statement.type === AST_NODE_TYPES.ReturnStatement &&
statement.argument !== null
) {
return getReturnedObjectExpressions(statement.argument)
}
return []
})
}
if (node.type === AST_NODE_TYPES.ConditionalExpression) {
return [
...getReturnedObjectExpressions(node.consequent),
...getReturnedObjectExpressions(node.alternate),
]
}
if (node.type === AST_NODE_TYPES.LogicalExpression) {
return [
...getReturnedObjectExpressions(node.left),
...getReturnedObjectExpressions(node.right),
]
}
if (node.type === AST_NODE_TYPES.SequenceExpression) {
return node.expressions.flatMap((expression) =>
getReturnedObjectExpressions(expression),
)
}
return []
}
function getQueryObjects(
node: TSESTree.Node,
): Array<TSESTree.ObjectExpression> {
if (node.type === AST_NODE_TYPES.ArrayExpression) {
return node.elements.flatMap((element) => {
if (element !== null && ASTUtils.isObjectExpression(element)) {
return [element]
}
return []
})
}
if (
node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.MemberExpression &&
ASTUtils.isIdentifierWithName(node.callee.property, 'map')
) {
const mapper = node.arguments[0]
if (
mapper?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
mapper?.type === AST_NODE_TYPES.FunctionExpression
) {
return getReturnedObjectExpressions(mapper)
}
}
return []
}
function isTanstackQueryClient(
node: TSESTree.Node,
context: Readonly<TSESLint.RuleContext<string, ReadonlyArray<unknown>>>,
helpers: Helpers,
): boolean {
const source = resolveQueryClientSource(node, context)
if (
source.type === AST_NODE_TYPES.CallExpression &&
ASTUtils.isIdentifier(source.callee)
) {
return (
getTanstackImportName(context, helpers, source.callee) ===
'useQueryClient'
)
}
if (
source.type === AST_NODE_TYPES.NewExpression &&
ASTUtils.isIdentifier(source.callee)
) {
return (
getTanstackImportName(context, helpers, source.callee) === 'QueryClient'
)
}
return false
}
function getTanstackImportName(
context: Readonly<TSESLint.RuleContext<string, ReadonlyArray<unknown>>>,
helpers: Helpers,
node: TSESTree.Identifier,
): string | null {
if (!helpers.isTanstackQueryImport(node)) {
return null
}
const definition = context.sourceCode
.getScope(node)
.references.find((reference) => reference.identifier === node)?.resolved
?.defs[0]?.node
if (
definition?.type !== AST_NODE_TYPES.ImportSpecifier ||
definition.imported.type !== AST_NODE_TYPES.Identifier
) {
return null
}
return definition.imported.name
}
function resolveQueryClientSource(
node: TSESTree.Node,
context: Readonly<TSESLint.RuleContext<string, ReadonlyArray<unknown>>>,
): TSESTree.Node {
const visitedNodes = new Set<TSESTree.Node>()
while (!visitedNodes.has(node)) {
visitedNodes.add(node)
if (node.type === AST_NODE_TYPES.ChainExpression) {
node = node.expression
continue
}
node = unwrapTypeAssertions(node)
if (node.type !== AST_NODE_TYPES.Identifier) {
return node
}
const expression = ASTUtils.getReferencedExpressionByIdentifier({
context,
node,
})
if (expression === null) {
return node
}
node = expression
}
return node
}
function unwrapTypeAssertions(node: TSESTree.Node): TSESTree.Node {
while (
node.type === AST_NODE_TYPES.TSAsExpression ||
node.type === AST_NODE_TYPES.TSSatisfiesExpression ||
node.type === AST_NODE_TYPES.TSTypeAssertion
) {
node = node.expression
}
return node
}

View File

@@ -0,0 +1,88 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import type { TSESLint } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'
export const name = 'stable-query-client'
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description: 'Makes sure that QueryClient is stable',
recommended: 'error',
},
messages: {
unstable: [
'QueryClient is not stable. It should be either extracted from the component or wrapped in React.useState.',
'See https://tkdodo.eu/blog/react-query-fa-qs#2-the-queryclient-is-not-stable',
].join('\n'),
fixTo: 'Fix to {{result}}',
},
hasSuggestions: true,
fixable: 'code',
schema: [],
},
defaultOptions: [],
create: detectTanstackQueryImports((context, _, helpers) => {
return {
NewExpression: (node) => {
if (
node.callee.type !== AST_NODE_TYPES.Identifier ||
node.callee.name !== 'QueryClient' ||
node.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
!helpers.isSpecificTanstackQueryImport(
node.callee,
'@tanstack/react-query',
)
) {
return
}
const fnAncestor = ASTUtils.getFunctionAncestor(
context.sourceCode,
node,
)
const isReactServerComponent = fnAncestor?.async === true
if (
!ASTUtils.isValidReactComponentOrHookName(fnAncestor?.id) ||
isReactServerComponent
) {
return
}
context.report({
node: node.parent,
messageId: 'unstable',
fix: (() => {
const { parent } = node
if (parent.id.type !== AST_NODE_TYPES.Identifier) {
return
}
// we need the fallbacks for backwards compat with eslint < 8.37.0
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const sourceCode = context.sourceCode ?? context.getSourceCode()
const nodeText = sourceCode.getText(node)
const variableName = parent.id.name
return (fixer: TSESLint.RuleFixer) => {
return fixer.replaceTextRange(
[parent.range[0], parent.range[1]],
`[${variableName}] = React.useState(() => ${nodeText})`,
)
}
})(),
})
},
}
}),
})

View File

@@ -0,0 +1,3 @@
export type ExtraRuleDocs = {
recommended: 'strict' | 'error' | 'warn'
}

View File

@@ -0,0 +1,213 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import { uniqueBy } from './unique-by'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
export const ASTUtils = {
isNodeOfOneOf<T extends AST_NODE_TYPES>(
node: TSESTree.Node,
types: ReadonlyArray<T>,
): node is TSESTree.Node & { type: T } {
return types.includes(node.type as T)
},
isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier {
return node.type === AST_NODE_TYPES.Identifier
},
isIdentifierWithName(
node: TSESTree.Node,
name: string,
): node is TSESTree.Identifier {
return ASTUtils.isIdentifier(node) && node.name === name
},
isIdentifierWithOneOfNames<T extends Array<string>>(
node: TSESTree.Node,
name: T,
): node is TSESTree.Identifier & { name: T[number] } {
return ASTUtils.isIdentifier(node) && name.includes(node.name)
},
isProperty(node: TSESTree.Node): node is TSESTree.Property {
return node.type === AST_NODE_TYPES.Property
},
isObjectExpression(node: TSESTree.Node): node is TSESTree.ObjectExpression {
return node.type === AST_NODE_TYPES.ObjectExpression
},
isPropertyWithIdentifierKey(
node: TSESTree.Node,
key: string,
): node is TSESTree.Property {
return (
ASTUtils.isProperty(node) && ASTUtils.isIdentifierWithName(node.key, key)
)
},
findPropertyWithIdentifierKey(
properties: Array<TSESTree.ObjectLiteralElement>,
key: string,
): TSESTree.Property | undefined {
return properties.find((x): x is TSESTree.Property =>
ASTUtils.isPropertyWithIdentifierKey(x, key),
)
},
traverseUpOnly(
identifier: TSESTree.Node,
allowedNodeTypes: Array<AST_NODE_TYPES>,
): TSESTree.Node {
const parent = identifier.parent
if (parent !== undefined && allowedNodeTypes.includes(parent.type)) {
return ASTUtils.traverseUpOnly(parent, allowedNodeTypes)
}
return identifier
},
traverseUpMemberExpression(node: TSESTree.Node): TSESTree.Node {
const parent = node.parent
if (
parent !== undefined &&
((parent.type === AST_NODE_TYPES.MemberExpression &&
parent.object === node) ||
parent.type === AST_NODE_TYPES.ChainExpression ||
parent.type === AST_NODE_TYPES.TSNonNullExpression)
) {
return ASTUtils.traverseUpMemberExpression(parent)
}
return node
},
isDeclaredInNode(params: {
functionNode: TSESTree.Node
reference: TSESLint.Scope.Reference
scopeManager: TSESLint.Scope.ScopeManager
}) {
const { functionNode, reference, scopeManager } = params
const scope = scopeManager.acquire(functionNode)
if (scope === null) {
return false
}
return scope.set.has(reference.identifier.name)
},
getExternalRefs(params: {
scopeManager: TSESLint.Scope.ScopeManager
sourceCode: Readonly<TSESLint.SourceCode>
node: TSESTree.Node
}): Array<TSESLint.Scope.Reference> {
const { scopeManager, sourceCode, node } = params
const scope = scopeManager.acquire(node)
if (scope === null) {
return []
}
const collectReferences = (
currentScope: TSESLint.Scope.Scope,
): Array<TSESLint.Scope.Reference> => {
const references = [...currentScope.references]
for (const childScope of currentScope.childScopes) {
references.push(...collectReferences(childScope))
}
return references
}
const references = collectReferences(scope)
.filter((x) => x.isRead() && !scope.set.has(x.identifier.name))
.map((x) => {
const memberPath = ASTUtils.traverseUpMemberExpression(x.identifier)
const memberExpression = memberPath.parent
const isComputedCallProperty =
memberExpression !== undefined &&
memberExpression.type === AST_NODE_TYPES.MemberExpression &&
memberExpression.computed &&
memberExpression.property === memberPath &&
memberExpression.parent.type === AST_NODE_TYPES.CallExpression &&
memberExpression.parent.callee === memberExpression
const referenceNode = isComputedCallProperty
? memberPath
: ASTUtils.traverseUpOnly(x.identifier, [
AST_NODE_TYPES.MemberExpression,
AST_NODE_TYPES.Identifier,
])
return {
variable: x,
node: referenceNode,
text: sourceCode.getText(referenceNode),
}
})
const localRefIds = new Set(
[...scope.set.values()].map((x) => sourceCode.getText(x.identifiers[0])),
)
const externalRefs = references.filter(
(x) => x.variable.resolved === null || !localRefIds.has(x.text),
)
return uniqueBy(externalRefs, (x) => x.text).map((x) => x.variable)
},
isValidReactComponentOrHookName(
identifier: TSESTree.Identifier | null | undefined,
) {
return (
identifier !== null &&
identifier !== undefined &&
/^(use|[A-Z])/.test(identifier.name)
)
},
getFunctionAncestor(
sourceCode: Readonly<TSESLint.SourceCode>,
node: TSESTree.Node,
) {
for (const ancestor of sourceCode.getAncestors(node)) {
if (
ASTUtils.isNodeOfOneOf(ancestor, [
AST_NODE_TYPES.FunctionDeclaration,
AST_NODE_TYPES.FunctionExpression,
AST_NODE_TYPES.ArrowFunctionExpression,
])
) {
return ancestor
}
if (
ancestor.parent?.type === AST_NODE_TYPES.VariableDeclarator &&
ancestor.parent.id.type === AST_NODE_TYPES.Identifier &&
ASTUtils.isNodeOfOneOf(ancestor, [
AST_NODE_TYPES.FunctionDeclaration,
AST_NODE_TYPES.FunctionExpression,
AST_NODE_TYPES.ArrowFunctionExpression,
])
) {
return ancestor
}
}
return undefined
},
getReferencedExpressionByIdentifier(params: {
node: TSESTree.Node
context: Readonly<TSESLint.RuleContext<string, ReadonlyArray<unknown>>>
}) {
const { node, context } = params
// we need the fallbacks for backwards compat with eslint < 8.37.0
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const sourceCode = context.sourceCode ?? context.getSourceCode()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const scope = context.sourceCode.getScope(node)
? sourceCode.getScope(node)
: context.getScope()
const resolvedNode = scope.references.find((ref) => ref.identifier === node)
?.resolved?.defs[0]?.node
if (resolvedNode?.type !== AST_NODE_TYPES.VariableDeclarator) {
return null
}
return resolvedNode.init
},
}

View File

@@ -0,0 +1,103 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { getDocsUrl } from './get-docs-url'
import { detectTanstackQueryImports } from './detect-react-query-imports'
import { sortDataByOrder } from './sort-data-by-order'
import type { ExtraRuleDocs } from '../types'
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
export function createPropertyOrderRule<
TFunc extends string,
TProp extends string,
>(
options: Omit<Parameters<typeof createRule>[0], 'create'>,
targetFunctions: ReadonlyArray<TFunc> | Array<TFunc>,
orderRules: ReadonlyArray<
Readonly<[ReadonlyArray<TProp>, ReadonlyArray<TProp>]>
>,
) {
const targetFunctionSet = new Set(targetFunctions)
function isTargetFunction(node: any): node is TFunc {
return targetFunctionSet.has(node)
}
return createRule({
...options,
create: detectTanstackQueryImports((context) => {
return {
CallExpression(node) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return
}
const functions = node.callee.name
if (!isTargetFunction(functions)) {
return
}
const argument = node.arguments[0]
if (argument === undefined || argument.type !== 'ObjectExpression') {
return
}
const allProperties = argument.properties
// no need to sort if there is at max 1 property
if (allProperties.length < 2) {
return
}
const properties = allProperties.flatMap((p, index) => {
if (
p.type === AST_NODE_TYPES.Property &&
p.key.type === AST_NODE_TYPES.Identifier
) {
return { name: p.key.name, property: p }
} else return { name: `_property_${index}`, property: p }
})
const sortedProperties = sortDataByOrder(
properties,
orderRules,
'name',
)
if (sortedProperties === null) {
return
}
context.report({
node: argument,
data: { function: node.callee.name },
messageId: 'invalidOrder',
fix(fixer) {
const sourceCode = context.sourceCode
const reorderedText = sortedProperties.reduce(
(sourceText, specifier, index) => {
let textBetweenProperties = ''
if (index < allProperties.length - 1) {
textBetweenProperties = sourceCode
.getText()
.slice(
allProperties[index]!.range[1],
allProperties[index + 1]!.range[0],
)
}
return (
sourceText +
sourceCode.getText(specifier.property) +
textBetweenProperties
)
},
'',
)
return fixer.replaceTextRange(
[allProperties[0]!.range[0], allProperties.at(-1)!.range[1]],
reorderedText,
)
},
})
},
}
}),
})
}

View File

@@ -0,0 +1,97 @@
import { TSESTree } from '@typescript-eslint/utils'
import type { ESLintUtils, TSESLint } from '@typescript-eslint/utils'
type Create = Parameters<
ReturnType<typeof ESLintUtils.RuleCreator>
>[0]['create']
type Context = Parameters<Create>[0]
type Options = Parameters<Create>[1]
type Helpers = {
isSpecificTanstackQueryImport: (
node: TSESTree.Identifier,
source: string,
) => boolean
isTanstackQueryImport: (node: TSESTree.Identifier) => boolean
}
type EnhancedCreate = (
context: Context,
options: Options,
helpers: Helpers,
) => ReturnType<Create>
export function detectTanstackQueryImports(create: EnhancedCreate): Create {
return (context, optionsWithDefault) => {
const tanstackQueryImportSpecifiers: Array<TSESTree.ImportClause> = []
const helpers: Helpers = {
isSpecificTanstackQueryImport(node, source) {
return !!tanstackQueryImportSpecifiers.find((specifier) => {
if (
specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier &&
specifier.parent.type ===
TSESTree.AST_NODE_TYPES.ImportDeclaration &&
specifier.parent.source.value === source
) {
return node.name === specifier.local.name
}
return false
})
},
isTanstackQueryImport(node) {
return !!tanstackQueryImportSpecifiers.find((specifier) => {
if (specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) {
return node.name === specifier.local.name
}
return false
})
},
}
const detectionInstructions: TSESLint.RuleListener = {
ImportDeclaration(node) {
if (
node.specifiers.length > 0 &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(node.importKind === 'value' || node.importKind === undefined) &&
node.source.value.startsWith('@tanstack/') &&
node.source.value.endsWith('-query')
) {
tanstackQueryImportSpecifiers.push(...node.specifiers)
}
},
}
// Call original rule definition
const ruleInstructions = create(context, optionsWithDefault, helpers)
const enhancedRuleInstructions: TSESLint.RuleListener = {}
const allKeys = new Set(
Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)),
)
// Iterate over ALL instructions keys so we can override original rule instructions
// to prevent their execution if conditions to report errors are not met.
allKeys.forEach((instruction) => {
enhancedRuleInstructions[instruction] = (node) => {
if (instruction in detectionInstructions) {
detectionInstructions[instruction]?.(node)
}
const ruleInstruction = ruleInstructions[instruction]
// TODO: canReportErrors()
if (ruleInstruction) {
return ruleInstruction(node)
}
return undefined
}
})
return enhancedRuleInstructions
}
}

View File

@@ -0,0 +1,2 @@
export const getDocsUrl = (ruleName: string): string =>
`https://tanstack.com/query/latest/docs/eslint/${ruleName}`

View File

@@ -0,0 +1,67 @@
export function sortDataByOrder<T, TKey extends keyof T>(
data: Array<T> | ReadonlyArray<T>,
orderRules: ReadonlyArray<
Readonly<[ReadonlyArray<T[TKey]>, ReadonlyArray<T[TKey]>]>
>,
key: TKey,
): Array<T> | null {
const getSubsetIndex = (
item: T[TKey],
subsets: ReadonlyArray<ReadonlyArray<T[TKey]> | Array<T[TKey]>>,
): number | null => {
for (let i = 0; i < subsets.length; i++) {
if (subsets[i]?.includes(item)) {
return i
}
}
return null
}
const orderSets = orderRules.reduce(
(sets, [A, B]) => [...sets, A, B],
[] as Array<ReadonlyArray<T[TKey]> | Array<T[TKey]>>,
)
const inOrderArray = data.filter(
(item) => getSubsetIndex(item[key], orderSets) !== null,
)
let wasResorted = false as boolean
// Sort by the relative order defined by the rules
const sortedArray = inOrderArray.sort((a, b) => {
const aKey = a[key],
bKey = b[key]
const aSubsetIndex = getSubsetIndex(aKey, orderSets)
const bSubsetIndex = getSubsetIndex(bKey, orderSets)
// If both items belong to different subsets, sort by their subset order
if (
aSubsetIndex !== null &&
bSubsetIndex !== null &&
aSubsetIndex !== bSubsetIndex
) {
return aSubsetIndex - bSubsetIndex
}
// If both items belong to the same subset or neither is in the subset, keep their relative order
return 0
})
const inOrderIterator = sortedArray.values()
const result = data.map((item) => {
if (getSubsetIndex(item[key], orderSets) !== null) {
const sortedItem = inOrderIterator.next().value!
if (sortedItem[key] !== item[key]) {
wasResorted = true
}
return sortedItem
}
return item
})
if (!wasResorted) {
return null
}
return result
}

View File

@@ -0,0 +1,3 @@
export function uniqueBy<T>(arr: Array<T>, fn: (x: T) => unknown): Array<T> {
return arr.filter((x, i, a) => a.findIndex((y) => fn(x) === fn(y)) === i)
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist-ts",
"rootDir": "."
},
"include": ["src", "*.config.ts", "*.config.js", "package.json"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"composite": false,
"rootDir": "../../"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
import { legacyConfig, modernConfig } from './root.tsdown.config.js'
export default defineConfig([
modernConfig({ entry: ['src/*.ts'] }),
legacyConfig({ entry: ['src/*.ts'] }),
])

View File

@@ -0,0 +1,41 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackViteConfig } from '@tanstack/vite-config'
import packageJson from './package.json'
const config = defineConfig({
// fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660
resolve: {
conditions: ['@tanstack/custom-condition'],
},
environments: {
ssr: {
resolve: {
conditions: ['@tanstack/custom-condition'],
},
},
},
test: {
name: packageJson.name,
dir: './src',
watch: false,
globals: true,
coverage: {
enabled: !!process.env.CI,
provider: 'istanbul',
include: ['src/**/*'],
exclude: ['src/__tests__/**'],
},
typecheck: { enabled: true },
restoreMocks: true,
},
})
export default mergeConfig(
config,
tanstackViteConfig({
entry: './src/index.ts',
srcDir: './src',
exclude: ['./src/__tests__'],
}),
)