diff --git a/.changeset/modern-llamas-warn.md b/.changeset/modern-llamas-warn.md new file mode 100644 index 00000000000..173b2bd5782 --- /dev/null +++ b/.changeset/modern-llamas-warn.md @@ -0,0 +1,5 @@ +--- +"@tanstack/eslint-plugin-query": patch +--- + +Fix stable-query-client detection for arrow functions, function expressions, and clients created directly in JSX. Keep state initializers and nested helper functions valid. diff --git a/docs/eslint/stable-query-client.md b/docs/eslint/stable-query-client.md index bf4e05efd91..341280f2f4a 100644 --- a/docs/eslint/stable-query-client.md +++ b/docs/eslint/stable-query-client.md @@ -9,6 +9,8 @@ The QueryClient contains the QueryCache, so you'd only want to create one instan ## Rule Details +This rule checks function declarations, function expressions, and arrow functions used as React components or custom hooks. It also reports clients created directly in JSX. Automatic fixes are available when the client is assigned to a variable. + Examples of **incorrect** code for this rule: ```tsx diff --git a/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts b/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts index fa896ff5790..acd1dbefb79 100644 --- a/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/stable-query-client.test.ts @@ -6,6 +6,19 @@ const ruleTester = new RuleTester() ruleTester.run('stable-query-client', rule, { valid: [ + ...[ + 'const Component = () => { const [client] = React.useState(() => new QueryClient()); return client; }', + 'const useClient = () => React.useState(() => new QueryClient());', + 'const Component = function() { const [client] = React.useState(() => new QueryClient()); return client; }', + 'function Component() { const [client] = React.useState(() => { const client = new QueryClient(); return client; }); return client; }', + 'const Component = () => { const [client] = React.useState(() => { const client = new QueryClient(); return client; }); return client; }', + 'function Component() { function createClient() { const client = new QueryClient(); return client; } return createClient; }', + 'const createClient = () => { const client = new QueryClient(); return client; }', + 'const ServerComponent = async () => { const client = new QueryClient(); return client; }', + 'const client = new QueryClient();', + ].map((code) => ({ + code: `import { QueryClient } from "@tanstack/react-query";\n${code}`, + })), { name: 'QueryClient is stable when wrapped in React.useState', code: normalizeIndent` @@ -119,6 +132,27 @@ ruleTester.run('stable-query-client', rule, { }, ], invalid: [ + ...['Component', 'useClient'].flatMap((name) => + ['() =>', 'function()'].map((fn) => ({ + code: `import { QueryClient } from "@tanstack/react-query"; +const ${name} = ${fn} { const client = new QueryClient(); return client; }`, + output: `import { QueryClient } from "@tanstack/react-query"; +const ${name} = ${fn} { const [client] = React.useState(() => new QueryClient()); return client; }`, + errors: [{ messageId: 'unstable' as const }], + })), + ), + ...[ + 'function Component() { return ; }', + 'const Component = () => { return ; }', + 'const Component = () => ;', + 'const useClient = () => new QueryClient();', + ].map((code) => ({ + filename: 'test.tsx', + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, + code: `import { QueryClient, QueryClientProvider } from "@tanstack/react-query";\n${code}`, + output: null, + errors: [{ messageId: 'unstable' as const }], + })), { name: 'QueryClient is not stable when it is not wrapped in React.useState in component', code: normalizeIndent` diff --git a/packages/eslint-plugin-query/src/rules/stable-query-client/stable-query-client.rule.ts b/packages/eslint-plugin-query/src/rules/stable-query-client/stable-query-client.rule.ts index 303ce19b8a6..58592c56a48 100644 --- a/packages/eslint-plugin-query/src/rules/stable-query-client/stable-query-client.rule.ts +++ b/packages/eslint-plugin-query/src/rules/stable-query-client/stable-query-client.rule.ts @@ -36,7 +36,6 @@ export const rule = createRule({ 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', @@ -45,26 +44,43 @@ export const rule = createRule({ return } - const fnAncestor = ASTUtils.getFunctionAncestor( - context.sourceCode, - node, - ) + const fnAncestor = context.sourceCode + .getAncestors(node) + .reverse() + .find((ancestor) => + ASTUtils.isNodeOfOneOf(ancestor, [ + AST_NODE_TYPES.FunctionDeclaration, + AST_NODE_TYPES.FunctionExpression, + AST_NODE_TYPES.ArrowFunctionExpression, + ]), + ) + const fnName = + fnAncestor?.parent.type === AST_NODE_TYPES.VariableDeclarator && + fnAncestor.parent.id.type === AST_NODE_TYPES.Identifier + ? fnAncestor.parent.id + : fnAncestor?.id const isReactServerComponent = fnAncestor?.async === true if ( - !ASTUtils.isValidReactComponentOrHookName(fnAncestor?.id) || + !ASTUtils.isValidReactComponentOrHookName(fnName) || isReactServerComponent ) { return } context.report({ - node: node.parent, + node: + node.parent.type === AST_NODE_TYPES.VariableDeclarator + ? node.parent + : node, messageId: 'unstable', fix: (() => { const { parent } = node - if (parent.id.type !== AST_NODE_TYPES.Identifier) { + if ( + parent.type !== AST_NODE_TYPES.VariableDeclarator || + parent.id.type !== AST_NODE_TYPES.Identifier + ) { return }