snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
3
packages/eslint-plugin-query/.attw.json
Normal file
3
packages/eslint-plugin-query/.attw.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignoreRules": ["false-export-default"]
|
||||
}
|
||||
177
packages/eslint-plugin-query/CHANGELOG.md
Normal file
177
packages/eslint-plugin-query/CHANGELOG.md
Normal 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))
|
||||
20
packages/eslint-plugin-query/eslint.config.js
Normal file
20
packages/eslint-plugin-query/eslint.config.js
Normal 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
77
packages/eslint-plugin-query/package.json
Normal file
77
packages/eslint-plugin-query/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/eslint-plugin-query/root.eslint.config.js
Symbolic link
1
packages/eslint-plugin-query/root.eslint.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../eslint.config.js
|
||||
49
packages/eslint-plugin-query/root.tsdown.config.js
Normal file
49
packages/eslint-plugin-query/root.tsdown.config.js
Normal 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
53
packages/eslint-plugin-query/src/__tests__/ast-utils.test.ts
Normal file
53
packages/eslint-plugin-query/src/__tests__/ast-utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
2199
packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
Normal file
2199
packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
101
packages/eslint-plugin-query/src/__tests__/test-utils.test.ts
Normal file
101
packages/eslint-plugin-query/src/__tests__/test-utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
108
packages/eslint-plugin-query/src/__tests__/test-utils.ts
Normal file
108
packages/eslint-plugin-query/src/__tests__/test-utils.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// File needs to exists
|
||||
20
packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts
vendored
Normal file
20
packages/eslint-plugin-query/src/__tests__/ts-fixture/react-query.d.ts
vendored
Normal 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>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["**/*"]
|
||||
}
|
||||
71
packages/eslint-plugin-query/src/index.ts
Normal file
71
packages/eslint-plugin-query/src/index.ts
Normal 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
|
||||
29
packages/eslint-plugin-query/src/rules.ts
Normal file
29
packages/eslint-plugin-query/src/rules.ts
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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()))
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})`,
|
||||
)
|
||||
}
|
||||
})(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
3
packages/eslint-plugin-query/src/types.ts
Normal file
3
packages/eslint-plugin-query/src/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type ExtraRuleDocs = {
|
||||
recommended: 'strict' | 'error' | 'warn'
|
||||
}
|
||||
213
packages/eslint-plugin-query/src/utils/ast-utils.ts
Normal file
213
packages/eslint-plugin-query/src/utils/ast-utils.ts
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
2
packages/eslint-plugin-query/src/utils/get-docs-url.ts
Normal file
2
packages/eslint-plugin-query/src/utils/get-docs-url.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const getDocsUrl = (ruleName: string): string =>
|
||||
`https://tanstack.com/query/latest/docs/eslint/${ruleName}`
|
||||
67
packages/eslint-plugin-query/src/utils/sort-data-by-order.ts
Normal file
67
packages/eslint-plugin-query/src/utils/sort-data-by-order.ts
Normal 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
|
||||
}
|
||||
3
packages/eslint-plugin-query/src/utils/unique-by.ts
Normal file
3
packages/eslint-plugin-query/src/utils/unique-by.ts
Normal 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)
|
||||
}
|
||||
8
packages/eslint-plugin-query/tsconfig.json
Normal file
8
packages/eslint-plugin-query/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "*.config.ts", "*.config.js", "package.json"]
|
||||
}
|
||||
10
packages/eslint-plugin-query/tsconfig.prod.json
Normal file
10
packages/eslint-plugin-query/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false,
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
7
packages/eslint-plugin-query/tsdown.config.ts
Normal file
7
packages/eslint-plugin-query/tsdown.config.ts
Normal 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'] }),
|
||||
])
|
||||
41
packages/eslint-plugin-query/vite.config.ts
Normal file
41
packages/eslint-plugin-query/vite.config.ts
Normal 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__'],
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user