-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(eslint-plugin-query): add prefer-query-options rule #10184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
79b459a
683f291
ee810e5
9025a63
9efb25f
bfacdaf
2723b55
cc7aaf4
5e35442
a5c65ec
d61a0f4
08dd540
cbcf084
df68e8b
85fb0de
a2f221c
1b25ed1
3fd7765
cfaec67
e2e7f1b
3029fcf
69d48ea
15c05dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/eslint-plugin-query': minor | ||
| --- | ||
|
|
||
| Add prefer-query-options rule |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { RuleTester } from '@typescript-eslint/rule-tester' | ||
| import { afterAll, describe, it } from 'vitest' | ||
| import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' | ||
|
|
||
| const ruleTester = new RuleTester() | ||
|
|
||
| RuleTester.afterAll = afterAll | ||
| RuleTester.describe = describe | ||
| RuleTester.it = it | ||
|
|
||
| // useQuery hooks | ||
| ruleTester.run(rule.name, rule, { | ||
| valid: [ | ||
| { code: `useQuery(usersQuery)` }, | ||
| { code: `useQuery({ ...usersQuery })` }, | ||
| { code: `useQuery({ ...usersQuery() })` }, | ||
| { code: `useQuery({ ...usersQuery, meta: {} })` }, | ||
| ], | ||
| invalid: [ | ||
| { | ||
| code: `useQuery({ queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `const users = useQuery({ ...queryOptions, queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `const users = useQuery({ queryFn: () => {} })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `useInfiniteQuery({ queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `useSuspenseQuery({ queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| { | ||
| code: `useSuspenseInfiniteQuery({ queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-hook' }], | ||
| }, | ||
| ], | ||
| }) | ||
|
|
||
| // queryClient.invalidateQueries expressions | ||
| ruleTester.run(rule.name, rule, { | ||
| valid: [ | ||
| { code: `queryClient.invalidateQueries(usersQuery)` }, | ||
| { code: `queryClient.invalidateQueries({ ...usersQuery })` }, | ||
| { code: `queryClient.invalidateQueries({ ...usersQuery() })` }, | ||
| ], | ||
| invalid: [ | ||
| { | ||
| code: `queryClient.invalidateQueries({ queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-invalidate' }], | ||
| }, | ||
| { | ||
| code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`, | ||
| errors: [{ messageId: 'no-inline-query-invalidate' }], | ||
| }, | ||
| { | ||
| code: `queryClient.invalidateQueries({ queryFn: () => {} })`, | ||
| errors: [{ messageId: 'no-inline-query-invalidate' }], | ||
| }, | ||
| { | ||
| code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`, | ||
| errors: [{ messageId: 'no-inline-query-invalidate' }], | ||
| }, | ||
| ], | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' | ||
| import { getDocsUrl } from '../../utils/get-docs-url' | ||
| import { detectQueryOptionsInObject } from './prefer-query-options.utils' | ||
| import type { TSESTree } from '@typescript-eslint/utils' | ||
| import type { ExtraRuleDocs } from '../../types' | ||
|
|
||
| export const name = 'prefer-query-options' | ||
|
|
||
| const useQueryHooks = [ | ||
| // see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery | ||
| 'useQuery', | ||
| // 'useQueries', // only works for single queries for now | ||
| 'useInfiniteQuery', | ||
| 'useSuspenseQuery', | ||
| // 'useSuspenseQueries', | ||
| 'useSuspenseInfiniteQuery', | ||
| ] | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl) | ||
|
|
||
| /** @returns true if it's a `useQuery` hook call expression node */ | ||
| function isQueryHookCallExpression(node: TSESTree.CallExpression) { | ||
| if (node.callee.type !== AST_NODE_TYPES.Identifier) return false | ||
| if (!useQueryHooks.includes(node.callee.name)) return false | ||
| return true | ||
| } | ||
|
|
||
| /** @returns true if it's a call to `queryClient.invalidateQueries` */ | ||
| function isInvalidateQueriesCallExpression(node: TSESTree.CallExpression) { | ||
| return ( | ||
| node.callee.type === AST_NODE_TYPES.MemberExpression && | ||
| node.callee.object.type === AST_NODE_TYPES.Identifier && | ||
| node.callee.object.name === 'queryClient' && | ||
| node.callee.property.type === AST_NODE_TYPES.Identifier && | ||
| node.callee.property.name === 'invalidateQueries' | ||
| ) | ||
| } | ||
|
Comment on lines
+29
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This will silently miss every real-world pattern where the QueryClient instance has any other name: const client = useQueryClient()
client.invalidateQueries({ queryKey: ['users'] }) // ❌ not caught
const qc = new QueryClient()
qc.invalidateQueries({ queryKey: ['users'] }) // ❌ not caughtThe most conservative broadening that avoids type-information requirements is to match any 🔧 Proposed fix function isInvalidateQueriesCallExpression(node: TSESTree.CallExpression) {
return (
node.callee.type === AST_NODE_TYPES.MemberExpression &&
- node.callee.object.type === AST_NODE_TYPES.Identifier &&
- node.callee.object.name === 'queryClient' &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.property.name === 'invalidateQueries'
)
}🤖 Prompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @TkDodo @Newbie012 do you have recommendations here? An alternative is to make this configurable. Additionally we could skip this from the rule for the first iteration.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export const rule = createRule({ | ||
| name, | ||
| meta: { | ||
| type: 'suggestion', | ||
| docs: { | ||
| description: | ||
| 'Ensures queryOptions constructor pattern is used when calling query apis', | ||
| recommended: 'strict', | ||
| }, | ||
| messages: { | ||
| 'no-inline-query-hook': 'Expected query hook to use queryOptions pattern', | ||
| 'no-inline-query-invalidate': | ||
| 'Expected query invalidate call to use queryOptions pattern', | ||
| }, | ||
| schema: [], | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| return { | ||
| CallExpression(node) { | ||
| // use*Query hook call | ||
| if ( | ||
| isQueryHookCallExpression(node) && | ||
| node.arguments[0] && | ||
| detectQueryOptionsInObject(node.arguments[0]) | ||
| ) { | ||
| context.report({ messageId: 'no-inline-query-hook', node }) | ||
| } | ||
|
|
||
| // queryClient.invalidateQueries call | ||
| if ( | ||
| isInvalidateQueriesCallExpression(node) && | ||
| node.arguments[0] && | ||
| detectQueryOptionsInObject(node.arguments[0]) | ||
| ) { | ||
| context.report({ messageId: 'no-inline-query-invalidate', node }) | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { AST_NODE_TYPES } from '@typescript-eslint/utils' | ||
| import type { TSESTree } from '@typescript-eslint/utils' | ||
|
|
||
| const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn'] | ||
|
|
||
| /** | ||
| * @returns true if the node is an object that has main query options (ie queryKey or queryFn). | ||
| * This is used for detecting inline query options in hooks and functions | ||
| */ | ||
| export function detectQueryOptionsInObject(queryNode: TSESTree.Node): boolean { | ||
| // skip if it's not an object | ||
| if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return false | ||
|
|
||
| // check if any of the properties is queryKey or queryFn | ||
| const hasMainQueryProperties = queryNode.properties.find( | ||
| (property) => | ||
| property.type === AST_NODE_TYPES.Property && | ||
| property.key.type === AST_NODE_TYPES.Identifier && | ||
| MAIN_QUERY_PROPERTIES.includes(property.key.name), | ||
| ) | ||
|
|
||
| return !!hasMainQueryProperties | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.