-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(eslint-plugin-query): add new rule for SSR QueryClient setup #10061
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
Open
jackleslie
wants to merge
4
commits into
TanStack:main
Choose a base branch
from
jackleslie:10060
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+366
−0
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@tanstack/eslint-plugin-query": minor | ||
| --- | ||
|
|
||
| feat(eslint-plugin-query): add new rule for SSR QueryClient setup |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| --- | ||
| id: no-module-query-client | ||
| title: Disallow module-level QueryClient in Next.js | ||
| --- | ||
|
|
||
| When doing server-side rendering with Next.js, it's critical to create the QueryClient instance inside your component (using React state or a ref), not at the module level. Creating the QueryClient at the file root makes the cache shared between all requests and users, which is bad for performance and can leak sensitive data. | ||
|
|
||
| > This rule only applies to Next.js projects (files in `pages/` or `app/` directories, or files named `_app` or `_document`). | ||
|
|
||
| ## Rule Details | ||
|
|
||
| Examples of **incorrect** code for this rule: | ||
|
|
||
| ```tsx | ||
| // pages/_app.tsx | ||
| /* eslint "@tanstack/query/no-module-query-client": "error" */ | ||
|
|
||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
|
|
||
| // NEVER DO THIS: | ||
| // Creating the queryClient at the file root level makes the cache shared | ||
| // between all requests and means _all_ data gets passed to _all_ users. | ||
| // Besides being bad for performance, this also leaks any sensitive data. | ||
| const queryClient = new QueryClient() | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| return ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <Component {...pageProps} /> | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| Examples of **correct** code for this rule: | ||
|
|
||
| ```tsx | ||
| // pages/_app.tsx | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
| import { useState } from 'react' | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| const [queryClient] = useState( | ||
| () => | ||
| new QueryClient({ | ||
| defaultOptions: { | ||
| queries: { | ||
| // With SSR, we usually want to set some default staleTime | ||
| // above 0 to avoid refetching immediately on the client | ||
| staleTime: 60 * 1000, | ||
| }, | ||
| }, | ||
| }), | ||
| ) | ||
|
|
||
| return ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <Component {...pageProps} /> | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| ## When Not To Use It | ||
|
|
||
| This rule is specifically designed for Next.js applications. If you're not using Next.js or server-side rendering, this rule may not be necessary. The rule only runs on files that appear to be in a Next.js project structure (`pages/`, `app/`, `_app`, `_document`). | ||
|
|
||
| ## Attributes | ||
|
|
||
| - [x] ✅ Recommended | ||
| - [ ] 🔧 Fixable | ||
218 changes: 218 additions & 0 deletions
218
packages/eslint-plugin-query/src/__tests__/no-module-query-client.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| import { RuleTester } from '@typescript-eslint/rule-tester' | ||
| import { rule } from '../rules/no-module-query-client/no-module-query-client.rule' | ||
| import { normalizeIndent } from './test-utils' | ||
|
|
||
| const ruleTester = new RuleTester() | ||
|
|
||
| ruleTester.run('no-module-query-client', rule, { | ||
| valid: [ | ||
| { | ||
| name: 'QueryClient created with React.useState in _app.tsx', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
| import React from "react"; | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| const [queryClient] = React.useState(() => new QueryClient()); | ||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient created with useState in _app.tsx', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
| import { useState } from "react"; | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| const [queryClient] = useState(() => new QueryClient()); | ||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient created with React.useRef in pages directory', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
| import React from "react"; | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| const queryClient = React.useRef(new QueryClient()); | ||
|
Collaborator
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 needs to work, too: |
||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient created in app directory with useState', | ||
| filename: 'app/layout.tsx', | ||
| code: normalizeIndent` | ||
| 'use client'; | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
| import { useState } from "react"; | ||
|
|
||
| export default function RootLayout({ children }) { | ||
| const [queryClient] = useState(() => new QueryClient()); | ||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient from different package at module level', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "some-other-package"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function MyApp() { | ||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient at module level in non-Next.js file', | ||
| filename: 'src/utils/query.ts', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export { queryClient }; | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient at module level in directory containing "app" as substring', | ||
| filename: 'myapp/components/Provider.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export { queryClient }; | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient in custom hook', | ||
| filename: 'pages/custom-hook.ts', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
| import { useState } from "react"; | ||
|
|
||
| export function useQueryClient() { | ||
| const [queryClient] = useState(() => new QueryClient()); | ||
| return queryClient; | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'QueryClient from solid-query at module level in pages', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/solid-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function MyApp() { | ||
| return null; | ||
| } | ||
| `, | ||
| }, | ||
| ], | ||
| invalid: [ | ||
| { | ||
| name: 'QueryClient created at module level in _app.tsx', | ||
| filename: 'pages/_app.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function MyApp({ Component, pageProps }) { | ||
| return null; | ||
| } | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'noModuleQueryClient', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| name: 'QueryClient created at module level in _document.tsx', | ||
| filename: 'pages/_document.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function Document() { | ||
| return null; | ||
| } | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'noModuleQueryClient', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| name: 'QueryClient created at module level in app directory', | ||
| filename: 'app/layout.tsx', | ||
| code: normalizeIndent` | ||
| 'use client'; | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function RootLayout({ children }) { | ||
| return null; | ||
| } | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'noModuleQueryClient', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| name: 'QueryClient created in pages directory page', | ||
| filename: 'pages/index.tsx', | ||
| code: normalizeIndent` | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const queryClient = new QueryClient(); | ||
|
|
||
| export default function HomePage() { | ||
| return null; | ||
| } | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'noModuleQueryClient', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| name: 'QueryClient created in app router page', | ||
| filename: 'app/dashboard/page.tsx', | ||
| code: normalizeIndent` | ||
| 'use client'; | ||
| import { QueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const client = new QueryClient(); | ||
|
|
||
| export default function Dashboard() { | ||
| return null; | ||
| } | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'noModuleQueryClient', | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 70 additions & 0 deletions
70
packages/eslint-plugin-query/src/rules/no-module-query-client/no-module-query-client.rule.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| 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 { ExtraRuleDocs } from '../../types' | ||
|
|
||
| export const name = 'no-module-query-client' | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl) | ||
|
|
||
| const isNextJsFile = (fileName: string): boolean => { | ||
| return ( | ||
| fileName.includes('_app.') || | ||
| fileName.includes('_document.') || | ||
| /(?:^|[/\\])(?:app|pages)[/\\]/.test(fileName) | ||
| ) | ||
| } | ||
|
|
||
| export const rule = createRule({ | ||
| name, | ||
| meta: { | ||
| type: 'problem', | ||
| docs: { | ||
| description: | ||
| 'Disallow module-level QueryClient in Next.js to prevent cache sharing', | ||
| recommended: 'error', | ||
| }, | ||
| messages: { | ||
| noModuleQueryClient: | ||
| 'QueryClient should not be created at module level in Next.js apps. Create it inside your component using useState or useRef.', | ||
| }, | ||
| schema: [], | ||
| }, | ||
| defaultOptions: [], | ||
|
|
||
| create: detectTanstackQueryImports((context, _, helpers) => { | ||
| const fileName = context.filename | ||
|
|
||
| if (!isNextJsFile(fileName)) { | ||
| return {} | ||
| } | ||
|
|
||
| return { | ||
| NewExpression: (node) => { | ||
| if ( | ||
| node.callee.type !== AST_NODE_TYPES.Identifier || | ||
| node.callee.name !== 'QueryClient' || | ||
| !helpers.isSpecificTanstackQueryImport( | ||
| node.callee, | ||
| '@tanstack/react-query', | ||
| ) | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| const functionAncestor = ASTUtils.getFunctionAncestor( | ||
| context.sourceCode, | ||
| node, | ||
| ) | ||
|
|
||
| if (functionAncestor === undefined) { | ||
| context.report({ | ||
| node, | ||
| messageId: 'noModuleQueryClient', | ||
| }) | ||
| } | ||
| }, | ||
| } | ||
| }), | ||
| }) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it’s too easy to hit false-positives here when someone has a client side rendered app that just happens to have a directory called
/pagesor/app.Being limited / bound to
next.jsis also not ideal as there are other frameworks doing SSR.Also, wouldn’t this fail in tests where queryClients might be created differently ? Not sure if tests can be in those directories though.
All in all, I think if we want this rule it should be opt-in and maybe needs to be able to customize locations to check ?