diff --git a/.changeset/prefer-query-options-rule.md b/.changeset/prefer-query-options-rule.md new file mode 100644 index 00000000000..bd33094aec8 --- /dev/null +++ b/.changeset/prefer-query-options-rule.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-query': minor +--- + +Add `prefer-query-options` rule and `recommendedStrict` config diff --git a/docs/config.json b/docs/config.json index 54eeb77e838..41200ceaed2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1293,6 +1293,10 @@ { "label": "Mutation Property Order", "to": "eslint/mutation-property-order" + }, + { + "label": "Prefer Query Options", + "to": "eslint/prefer-query-options" } ] }, diff --git a/docs/eslint/eslint-plugin-query.md b/docs/eslint/eslint-plugin-query.md index 8b6ae637dec..8a1d726884b 100644 --- a/docs/eslint/eslint-plugin-query.md +++ b/docs/eslint/eslint-plugin-query.md @@ -46,6 +46,19 @@ export default [ ] ``` +### Recommended strict setup + +The `flat/recommended-strict` config extends `flat/recommended` with additional opinionated rules that enforce best practices more aggressively. + +```js +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default [ + ...pluginQuery.configs['flat/recommended-strict'], + // Any other config... +] +``` + ### Custom setup Alternatively, you can load the plugin and configure only the rules you want to use: @@ -78,6 +91,16 @@ To enable all of the recommended rules for our plugin, add `plugin:@tanstack/que } ``` +### Recommended strict setup + +The `recommendedStrict` config extends `recommended` with additional opinionated rules: + +```json +{ + "extends": ["plugin:@tanstack/query/recommendedStrict"] +} +``` + ### Custom setup Alternatively, add `@tanstack/query` to the plugins section, and configure the rules you want to use: @@ -100,3 +123,4 @@ Alternatively, add `@tanstack/query` to the plugins section, and configure the r - [@tanstack/query/infinite-query-property-order](./infinite-query-property-order.md) - [@tanstack/query/no-void-query-fn](./no-void-query-fn.md) - [@tanstack/query/mutation-property-order](./mutation-property-order.md) +- [@tanstack/query/prefer-query-options](./prefer-query-options.md) diff --git a/docs/eslint/prefer-query-options.md b/docs/eslint/prefer-query-options.md new file mode 100644 index 00000000000..3228774bf7d --- /dev/null +++ b/docs/eslint/prefer-query-options.md @@ -0,0 +1,145 @@ +--- +id: prefer-query-options +title: Prefer the use of queryOptions +--- + +Separating `queryKey` and `queryFn` can cause unexpected runtime issues when the same query key is accidentally used with more than one `queryFn`. Wrapping them in `queryOptions` (or `infiniteQueryOptions`) co-locates the key and function, making queries safer and easier to reuse. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function Component({ id }) { + const query = useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function useFooQuery(id) { + return useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} +``` + +Examples of **correct** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} + +function Component({ id }) { + const query = useQuery(getFooOptions(id)) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} + +function useFooQuery(id) { + return useQuery({ ...getFooOptions(id), select: (data) => data.foo }) +} +``` + +The rule also enforces reusing `queryKey` from a `queryOptions` result instead of typing it manually in `QueryClient` methods or filters. + +Examples of **incorrect** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(['todo', id]) +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: ['todo', id] }) +} +``` + +Examples of **correct** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(todoOptions(id).queryKey) +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} + +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: todoOptions(id).queryKey }) +} +``` + +## When Not To Use It + +If you do not want to enforce the use of `queryOptions` in your codebase, you will not need this rule. + +## Attributes + +- [x] ✅ Recommended (strict) +- [ ] 🔧 Fixable diff --git a/examples/react/eslint-plugin-demo/eslint.config.js b/examples/react/eslint-plugin-demo/eslint.config.js index 1e9a5820b5d..85fa95bb76b 100644 --- a/examples/react/eslint-plugin-demo/eslint.config.js +++ b/examples/react/eslint-plugin-demo/eslint.config.js @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint' export default [ ...tseslint.configs.recommended, - ...pluginQuery.configs['flat/recommended'], + ...pluginQuery.configs['flat/recommended-strict'], { files: ['src/**/*.ts', 'src/**/*.tsx'], rules: { diff --git a/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx new file mode 100644 index 00000000000..d992faa55a8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx @@ -0,0 +1,49 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { todoOptions, todosOptions } from './queries' + +// ✅ passes: consuming imported queryOptions result directly +export function Todos() { + const query = useQuery(todosOptions) + return query.data +} + +// ✅ passes: spreading imported queryOptions result with additional options +export function Todo({ id }: { id: string }) { + const query = useQuery({ + ...todoOptions(id), + select: (data) => data.title, + }) + return query.data +} + +// ✅ passes: referencing queryKey from imported queryOptions result +export function invalidateTodos() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) +} + +// ❌ fails: inline queryKey + queryFn should use queryOptions() +export function TodosBad() { + const query = useQuery( + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn. + { + queryKey: ['todos'], + queryFn: () => fetch('/api/todos').then((r) => r.json()), + }, + ) + return query.data +} + +// ❌ fails: inline queryKey should reference queryOptions result +export function InvalidateTodosBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + queryClient.invalidateQueries({ queryKey: ['todos'] }) +} + +// ❌ fails: inline queryKey as direct parameter +export function GetTodosDataBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + return queryClient.getQueryData(['todos']) +} diff --git a/examples/react/eslint-plugin-demo/src/queries.tsx b/examples/react/eslint-plugin-demo/src/queries.tsx new file mode 100644 index 00000000000..46a86734eb8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/queries.tsx @@ -0,0 +1,23 @@ +import { queryOptions } from '@tanstack/react-query' + +// ✅ passes: queryKey and queryFn are co-located via queryOptions() +export const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), +}) + +// ✅ passes: factory function wrapping queryOptions() +export const todoOptions = (id: string) => + queryOptions({ + queryKey: ['todo', id], + queryFn: () => fetchTodo(id), + }) + +function fetchTodos(): Promise> { + throw new Error('not implemented') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function fetchTodo(id: string): Promise<{ id: string; title: string }> { + throw new Error('not implemented') +} diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts new file mode 100644 index 00000000000..ff5962623ba --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -0,0 +1,1019 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' +import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' +import { normalizeIndent } from './test-utils' + +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester() + +describe('prefer-query-options', () => { + describe('queryOptions / infiniteQueryOptions builders', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryOptions builder is allowed', + code: normalizeIndent` + import { queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + }, + { + name: 'infiniteQueryOptions builder is allowed', + code: normalizeIndent` + import { infiniteQueryOptions } from '@tanstack/react-query' + + const todosOptions = infiniteQueryOptions({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + `, + }, + ], + invalid: [], + }) + }) + + describe('hooks consuming queryOptions result', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'useQuery with queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery(todosOptions) + return null + } + `, + }, + { + name: 'useQuery with queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery(todosOptions(id)) + return null + } + `, + }, + { + name: 'useQuery with imported queryOptions function call is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + import { getFooOptions } from './foo' + + function Component({ id }) { + const query = useQuery(getFooOptions(id)) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery({ ...todosOptions, select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery({ ...todosOptions(id), select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQueries with all entries from queryOptions is allowed', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [todosOptions, usersOptions], + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('queryClient methods referencing queryKey from options', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryClient.getQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(todosOptions.queryKey) + return null + } + `, + }, + { + name: 'queryClient.setQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(todosOptions.queryKey, []) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey and extra filter props is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey, exact: true }) + return null + } + `, + }, + { + name: 'queryClient.getQueryData with variable queryKey is allowed', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component({ queryKey }) { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(queryKey) + return null + } + `, + }, + { + name: 'shadowed queryClient parameter is ignored', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + + function run(queryClient) { + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + + return null + } + `, + }, + { + name: 'non-queryClient fetchQuery call is ignored', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const analytics = { + fetchQuery(options) { + return options + }, + } + + function Component() { + useQuery(todosOptions) + + analytics.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('non-tanstack imports', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'non-tanstack useQuery is ignored', + code: normalizeIndent` + import { useQuery } from 'other-library' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('inline lone queryKey or queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with only inline queryKey (no queryFn)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery with only inline queryFn (no queryKey)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryFn: () => fetchTodos() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('spread with inline queryKey or queryFn override in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery spreading options but overriding queryKey inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryKey: ['override'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery spreading options but overriding queryFn inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryFn: () => fetchOverride() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery as useTanstackQuery } from '@tanstack/react-query' + + function Component() { + const query = useTanstackQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with multiple inline entries reports multiple errors', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + { + queryKey: ['users'], + queryFn: () => fetchUsers(), + }, + ], + }) + return null + } + `, + errors: [ + { messageId: 'preferQueryOptions' }, + { messageId: 'preferQueryOptions' }, + ], + }, + { + name: 'useQueries with mapped inline query objects', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component() { + const queries = useSuspenseQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with mapped inline query objects', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useSuspenseQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchInfiniteQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'inline queryKey + queryFn inside a custom hook', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function useTodos() { + return useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('queryClient with alternate variable names', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'client.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased useQueryClient tracks query client variables', + code: normalizeIndent` + import { useQueryClient as getClient } from '@tanstack/react-query' + + function Component() { + const client = getClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased QueryClient tracks query client instances', + code: normalizeIndent` + import { QueryClient as Client } from '@tanstack/react-query' + + const queryClient = new Client() + + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'qc.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const qc = useQueryClient() + const data = qc.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'client.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in queryClient methods', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.fetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureQueryData({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureInfiniteQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureInfiniteQueryData({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey as direct parameter', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryData with inline queryKey as const', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos'] as const) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryData with inline queryKey satisfies', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData((['todos']) satisfies readonly string[]) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(['todos'], []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryState with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const state = queryClient.getQueryState(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryDefaults(['todos'], { staleTime: 1000 }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const defaults = queryClient.getQueryDefaults(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey in filter objects', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.invalidateQueries with inline queryKey as const in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] as const }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.invalidateQueries with inline queryKey satisfies in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ + queryKey: (['todos']) satisfies readonly string[], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.cancelQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.cancelQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.refetchQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.refetchQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.removeQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.removeQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.resetQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.resetQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.isFetching with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const count = queryClient.isFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueriesData({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueriesData({ queryKey: ['todos'] }, []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'useIsFetching with inline queryKey in filters', + code: normalizeIndent` + import { useIsFetching } from '@tanstack/react-query' + + function Component() { + const count = useIsFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) +}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 452bc0f5a42..085ba7b9f3d 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -8,10 +8,27 @@ export interface Plugin extends Omit { rules: Record> configs: { recommended: ESLint.ConfigData + recommendedStrict: ESLint.ConfigData 'flat/recommended': Array + 'flat/recommended-strict': Array } } +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', @@ -19,15 +36,11 @@ export const plugin = { configs: { recommended: { plugins: ['@tanstack/query'], - rules: { - '@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', - }, + rules: recommendedRules, + }, + recommendedStrict: { + plugins: ['@tanstack/query'], + rules: recommendedStrictRules, }, 'flat/recommended': [ { @@ -35,15 +48,16 @@ export const plugin = { plugins: { '@tanstack/query': {}, // Assigned after plugin object created }, - rules: { - '@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', + rules: recommendedRules, + }, + ], + 'flat/recommended-strict': [ + { + name: 'tanstack/query/flat/recommended-strict', + plugins: { + '@tanstack/query': {}, // Assigned after plugin object created }, + rules: recommendedStrictRules, }, ], }, @@ -51,5 +65,7 @@ export const plugin = { } satisfies Plugin plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin +plugin.configs['flat/recommended-strict'][0]!.plugins['@tanstack/query'] = + plugin export default plugin diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1d..cbae59eb9c9 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,6 +5,7 @@ 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' @@ -24,4 +25,5 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, + [preferQueryOptions.name]: preferQueryOptions.rule, } diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts new file mode 100644 index 00000000000..b7e65e9907e --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -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(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 { + 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 { + 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>>, + 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>>, + 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>>, +): TSESTree.Node { + const visitedNodes = new Set() + + 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 +}