diff --git a/docs/eslint/eslint-plugin-query.md b/docs/eslint/eslint-plugin-query.md index bc092f3b77..834a3ac751 100644 --- a/docs/eslint/eslint-plugin-query.md +++ b/docs/eslint/eslint-plugin-query.md @@ -46,6 +46,21 @@ export default [ ] ``` +### Recommended type-checked setup + +If you're using TypeScript and want to enable rules that require type information, you can use the `flat/recommendedTypeChecked` config: + +```js +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default [ + ...pluginQuery.configs['flat/recommendedTypeChecked'], + // Any other config... +] +``` + +> â„šī¸ This setup requires type-aware linting. You can follow the [TypeScript ESLint documentation on type-checking](https://typescript-eslint.io/linting/typed-linting/) to set up your ESLint config accordingly. + ### Custom setup Alternatively, you can load the plugin and configure only the rules you want to use: diff --git a/docs/eslint/no-void-query-fn.md b/docs/eslint/no-void-query-fn.md index 445c437751..baf9a06080 100644 --- a/docs/eslint/no-void-query-fn.md +++ b/docs/eslint/no-void-query-fn.md @@ -36,4 +36,5 @@ useQuery({ ## Attributes - [x] ✅ Recommended +- [x] 💭 Type checked - [ ] 🔧 Fixable diff --git a/examples/react/eslint-type-checked/.gitignore b/examples/react/eslint-type-checked/.gitignore new file mode 100644 index 0000000000..4673b022e5 --- /dev/null +++ b/examples/react/eslint-type-checked/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/eslint-type-checked/README.md b/examples/react/eslint-type-checked/README.md new file mode 100644 index 0000000000..1cf8892652 --- /dev/null +++ b/examples/react/eslint-type-checked/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/eslint-type-checked/eslint.config.js b/examples/react/eslint-type-checked/eslint.config.js new file mode 100644 index 0000000000..28f056b6bb --- /dev/null +++ b/examples/react/eslint-type-checked/eslint.config.js @@ -0,0 +1,20 @@ +// @ts-check + +import eslint from '@eslint/js' +import tseslint from 'typescript-eslint' +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default tseslint.config( + eslint.configs.recommended, + tseslint.configs.recommended, + pluginQuery.configs['flat/recommendedTypeChecked'], + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +) diff --git a/examples/react/eslint-type-checked/index.html b/examples/react/eslint-type-checked/index.html new file mode 100644 index 0000000000..d7c231330c --- /dev/null +++ b/examples/react/eslint-type-checked/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Query React Basic Example App + + + +
+ + + diff --git a/examples/react/eslint-type-checked/package.json b/examples/react/eslint-type-checked/package.json new file mode 100644 index 0000000000..65740bb9f4 --- /dev/null +++ b/examples/react/eslint-type-checked/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tanstack/query-example-react-eslint-type-checked", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/query-sync-storage-persister": "^5.71.10", + "@tanstack/react-query": "^5.71.10", + "@tanstack/react-query-devtools": "^5.71.10", + "@tanstack/react-query-persist-client": "^5.71.10", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.71.5", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "5.8.2", + "vite": "^6.2.4" + } +} diff --git a/examples/react/eslint-type-checked/public/emblem-light.svg b/examples/react/eslint-type-checked/public/emblem-light.svg new file mode 100644 index 0000000000..a58e69ad5e --- /dev/null +++ b/examples/react/eslint-type-checked/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/eslint-type-checked/src/index.tsx b/examples/react/eslint-type-checked/src/index.tsx new file mode 100644 index 0000000000..052ce4c797 --- /dev/null +++ b/examples/react/eslint-type-checked/src/index.tsx @@ -0,0 +1,161 @@ +import * as React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, // 24 hours + }, + }, +}) + +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}) + +type Post = { + id: number + title: string + body: string +} + +function usePosts() { + return useQuery({ + queryKey: ['posts'], + queryFn: async (): Promise> => { + const response = await fetch('https://jsonplaceholder.typicode.com/posts') + return await response.json() + }, + }) +} + +function Posts({ + setPostId, +}: { + setPostId: React.Dispatch> +}) { + const queryClient = useQueryClient() + const { status, data, error, isFetching } = usePosts() + + return ( +
+

Posts

+
+ {status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +
+ {data.map((post) => ( +

+ setPostId(post.id)} + href="#" + style={ + // We can access the query data here to show bold links for + // ones that are cached + queryClient.getQueryData(['post', post.id]) + ? { + fontWeight: 'bold', + color: 'green', + } + : {} + } + > + {post.title} + +

+ ))} +
+
{isFetching ? 'Background Updating...' : ' '}
+ + )} +
+
+ ) +} + +const getPostById = async (id: number): Promise => { + const response = await fetch( + `https://jsonplaceholder.typicode.com/posts/${id}`, + ) + return await response.json() +} + +function usePost(postId: number) { + return useQuery({ + queryKey: ['post', postId], + queryFn: () => getPostById(postId), + enabled: !!postId, + }) +} + +function Post({ + postId, + setPostId, +}: { + postId: number + setPostId: React.Dispatch> +}) { + const { status, data, error, isFetching } = usePost(postId) + + return ( +
+ + {!postId || status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +

{data.title}

+
+

{data.body}

+
+
{isFetching ? 'Background Updating...' : ' '}
+ + )} +
+ ) +} + +function App() { + const [postId, setPostId] = React.useState(-1) + + return ( + +

+ As you visit the posts below, you will notice them in a loading state + the first time you load them. However, after you return to this list and + click on any posts you have already visited again, you will see them + load instantly and background refresh right before your eyes!{' '} + + (You may need to throttle your network speed to simulate longer + loading sequences) + +

+ {postId > -1 ? ( + + ) : ( + + )} + +
+ ) +} + +const rootElement = document.getElementById('root') as HTMLElement +ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/eslint-type-checked/tsconfig.json b/examples/react/eslint-type-checked/tsconfig.json new file mode 100644 index 0000000000..bb58f59b5b --- /dev/null +++ b/examples/react/eslint-type-checked/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "eslint.config.js", "vite.config.ts"] +} diff --git a/examples/react/eslint-type-checked/vite.config.ts b/examples/react/eslint-type-checked/vite.config.ts new file mode 100644 index 0000000000..9ffcc67574 --- /dev/null +++ b/examples/react/eslint-type-checked/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts index c136a2582c..a6a36e42f4 100644 --- a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts @@ -8,7 +8,7 @@ RuleTester.afterAll = afterAll RuleTester.describe = describe RuleTester.it = it -const ruleTester = new RuleTester({ +const ruleTesterTypeChecked = new RuleTester({ languageOptions: { parser: await import('@typescript-eslint/parser'), parserOptions: { @@ -18,7 +18,7 @@ const ruleTester = new RuleTester({ }, }) -ruleTester.run('no-void-query-fn', rule, { +ruleTesterTypeChecked.run('no-void-query-fn', rule, { valid: [ { name: 'queryFn returns a value', @@ -323,3 +323,31 @@ ruleTester.run('no-void-query-fn', rule, { }, ], }) + +const ruleTester = new RuleTester({ + languageOptions: { + parser: await import('@typescript-eslint/parser'), + }, +}) + +ruleTester.run('no-void-query-fn with no program', rule, { + valid: [], + 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: 'noProgram' }], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index f16cb985b8..7d83ac573d 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -1,6 +1,7 @@ import { rules } from './rules' import type { ESLint, Linter } from 'eslint' import type { RuleModule } from '@typescript-eslint/utils/ts-eslint' +import type { TSESLint } from '@typescript-eslint/utils' type RuleKey = keyof typeof rules @@ -8,7 +9,9 @@ export interface Plugin extends Omit { rules: Record> configs: { recommended: ESLint.ConfigData + recommendedTypeChecked: ESLint.ConfigData 'flat/recommended': Array + 'flat/recommendedTypeChecked': Array } } @@ -20,35 +23,46 @@ const plugin: Plugin = { rules, } +const rulesRecord: TSESLint.SharedConfig.RulesRecord = { + '@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', +} + +const rulesTypeCheckedRecord: TSESLint.SharedConfig.RulesRecord = { + ...rulesRecord, + '@tanstack/query/no-void-query-fn': 'error', +} + // Assign configs here so we can reference `plugin` Object.assign(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', - }, + rules: rulesRecord, + }, + recommendedTypeChecked: { + plugins: ['@tanstack/query'], + rules: rulesTypeCheckedRecord, }, 'flat/recommended': [ { name: 'tanstack/query/flat/recommended', - plugins: { - '@tanstack/query': plugin, - }, - 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', - }, + plugins: { '@tanstack/query': plugin }, + rules: rulesRecord, + }, + ], + 'flat/recommendedTypeChecked': [ + { + name: 'tanstack/query/flat/recommendedTypeChecked', + plugins: { '@tanstack/query': plugin }, + rules: rulesTypeCheckedRecord, }, ], -}) +} satisfies Record< + string, + TSESLint.FlatConfig.ConfigArray | TSESLint.ClassicConfig.Config +>) export default plugin diff --git a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts index 9c202e621a..cdb7a67a96 100644 --- a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts +++ b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts @@ -19,6 +19,7 @@ export const rule = createRule({ }, messages: { noVoidReturn: 'queryFn must return a non-undefined value', + noProgram: `Type information is not available for this file. See https://typescript-eslint.io/getting-started/typed-linting/ for how to set this up.`, }, schema: [], }, @@ -41,7 +42,10 @@ export const rule = createRule({ !parserServices.esTreeNodeToTSNodeMap || !parserServices.program ) { - return + return context.report({ + node: node.value, + messageId: 'noProgram', + }) } const checker = parserServices.program.getTypeChecker() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b2acc6a7e..a77da9520e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -809,6 +809,46 @@ importers: specifier: ^6.2.4 version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1) + examples/react/eslint-type-checked: + dependencies: + '@tanstack/query-sync-storage-persister': + specifier: workspace:* + version: link:../../../packages/query-sync-storage-persister + '@tanstack/react-query': + specifier: workspace:* + version: link:../../../packages/react-query + '@tanstack/react-query-devtools': + specifier: workspace:* + version: link:../../../packages/react-query-devtools + '@tanstack/react-query-persist-client': + specifier: workspace:* + version: link:../../../packages/react-query-persist-client + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@tanstack/eslint-plugin-query': + specifier: workspace:* + version: link:../../../packages/eslint-plugin-query + '@types/react': + specifier: ^19.0.1 + version: 19.0.1 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.0.2(@types/react@19.0.1) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1)) + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^6.2.4 + version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1) + examples/react/infinite-query-with-max-pages: dependencies: '@tanstack/react-query':