From 14d88f395ca65875111784256112018b50fd15e8 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Mon, 26 Oct 2026 17:40:18 +0100 Subject: [PATCH] fix(eslint-plugin-query): detect unstable clients in arrow functions and JSX --- .changeset/modern-llamas-warn.md | 5 +++ docs/eslint/stable-query-client.md | 2 ++ .../src/__tests__/stable-query-client.test.ts | 34 +++++++++++++++++++ .../stable-query-client.rule.ts | 32 ++++++++++++----- 4 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 .changeset/modern-llamas-warn.md 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 }