diff --git a/docs/config.json b/docs/config.json index bde7e2d5df..b87d7987ef 100644 --- a/docs/config.json +++ b/docs/config.json @@ -303,6 +303,10 @@ "label": "Exhaustive Deps", "to": "react/eslint/exhaustive-deps" }, + { + "label": "No deprecated options", + "to": "react/eslint/no-deprecated-options" + }, { "label": "Prefer object syntax", "to": "react/eslint/prefer-query-object-syntax" diff --git a/docs/react/eslint/eslint-plugin-query.md b/docs/react/eslint/eslint-plugin-query.md index e470a59f19..e7132497b1 100644 --- a/docs/react/eslint/eslint-plugin-query.md +++ b/docs/react/eslint/eslint-plugin-query.md @@ -33,6 +33,7 @@ Then configure the rules you want to use under the rules section: { "rules": { "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-deprecated-options": "error", "@tanstack/query/prefer-query-object-syntax": "error", "@tanstack/query/stable-query-client": "error" } diff --git a/docs/react/eslint/no-deprecated-options.md b/docs/react/eslint/no-deprecated-options.md new file mode 100644 index 0000000000..eb5501ccb4 --- /dev/null +++ b/docs/react/eslint/no-deprecated-options.md @@ -0,0 +1,65 @@ +--- +id: no-deprecated-options +title: Disallowing deprecated options +--- + +This rule warns about deprecated [`useQuery`](https://tanstack.com/query/v4/docs/reference/useQuery) options which will be removed in [TanStack Query v5](https://tanstack.com/query/v5/docs/react/guides/migrating-to-v5). + +## Rule Details + +Examples of **incorrect** code for this rule: + +```tsx +/* eslint "@tanstack/query/no-deprecated-options": "error" */ + +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), + onSuccess: () => {}, +}) + +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), + onError: () => {}, +}) + +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), + onSettled: () => {}, +}) + +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), + isDataEqual: (oldData, newData) => customCheck(oldData, newData), +}) +``` + +Examples of **correct** code for this rule: + +```tsx +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), +}) + +useQuery({ + queryKey: ['todo', todoId], + queryFn: () => api.getTodo(todoId), + structuralSharing: (oldData, newData) => + customCheck(oldData, newData) + ? oldData + : replaceEqualDeep(oldData, newData), +}) +``` + +## When Not To Use It + +If you don't plan to upgrade to TanStack Query v5, then you will not need this rule. + +## Attributes + +- [x] ✅ Recommended +- [ ] 🔧 Fixable diff --git a/packages/eslint-plugin-query/src/configs/index.test.ts b/packages/eslint-plugin-query/src/configs/index.test.ts index 6bab6df741..4cd8c5312b 100644 --- a/packages/eslint-plugin-query/src/configs/index.test.ts +++ b/packages/eslint-plugin-query/src/configs/index.test.ts @@ -10,6 +10,7 @@ describe('configs', () => { ], "rules": { "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-deprecated-options": "error", "@tanstack/query/prefer-query-object-syntax": "error", "@tanstack/query/stable-query-client": "error", }, diff --git a/packages/eslint-plugin-query/src/rules/index.ts b/packages/eslint-plugin-query/src/rules/index.ts index 392479bf6b..ae01c20916 100644 --- a/packages/eslint-plugin-query/src/rules/index.ts +++ b/packages/eslint-plugin-query/src/rules/index.ts @@ -1,9 +1,11 @@ import * as exhaustiveDeps from './exhaustive-deps/exhaustive-deps.rule' +import * as noDeprecatedOptions from './no-deprecated-options/no-deprecated-options.rule' import * as preferObjectSyntax from './prefer-query-object-syntax/prefer-query-object-syntax' import * as stableQueryClient from './stable-query-client/stable-query-client.rule' export const rules = { [exhaustiveDeps.name]: exhaustiveDeps.rule, + [noDeprecatedOptions.name]: noDeprecatedOptions.rule, [preferObjectSyntax.name]: preferObjectSyntax.rule, [stableQueryClient.name]: stableQueryClient.rule, } diff --git a/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.rule.ts b/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.rule.ts new file mode 100644 index 0000000000..97ec1e0731 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.rule.ts @@ -0,0 +1,166 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import { createRule } from '../../utils/create-rule' +import { ASTUtils } from '../../utils/ast-utils' +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' + +const ON_SUCCESS = 'onSuccess' +const ON_ERROR = 'onError' +const ON_SETTLED = 'onSettled' +const IS_DATA_EQUAL = 'isDataEqual' + +const DEPRECATED_OPTIONS = [ + ON_SUCCESS, + ON_ERROR, + ON_SETTLED, + IS_DATA_EQUAL, +] as const + +const QUERY_CALLS = ['useQuery' as const] + +const messages = { + noDeprecatedOptions: `Option \`{{option}}\` will be removed in the next major version`, +} + +type MessageKey = keyof typeof messages + +export const name = 'no-deprecated-options' + +export const rule: TSESLint.RuleModule = + createRule({ + name, + meta: { + type: 'problem', + docs: { + description: 'Disallows deprecated options', + recommended: 'error', + }, + messages: messages, + schema: [], + }, + defaultOptions: [], + + create(context, _, helpers) { + return { + CallExpression(node) { + if ( + !ASTUtils.isIdentifierWithOneOfNames(node.callee, QUERY_CALLS) || + !helpers.isTanstackQueryImport(node.callee) + ) { + return + } + + const firstArgument = node.arguments[0] + + if (!firstArgument) { + return + } + + if ( + node.arguments.length === 1 && + firstArgument.type === AST_NODE_TYPES.CallExpression + ) { + const referencedCallExpression = + ASTUtils.getReferencedExpressionByIdentifier({ + context, + node: firstArgument.callee, + }) + + if ( + referencedCallExpression === null || + !ASTUtils.isNodeOfOneOf(referencedCallExpression, [ + AST_NODE_TYPES.ArrowFunctionExpression, + AST_NODE_TYPES.FunctionDeclaration, + AST_NODE_TYPES.FunctionExpression, + ]) + ) { + return + } + + if ( + referencedCallExpression.type === + AST_NODE_TYPES.ArrowFunctionExpression && + referencedCallExpression.expression + ) { + return runCheckOnNode({ + context: context, + callNode: node, + expression: referencedCallExpression.body, + messageId: 'noDeprecatedOptions', + }) + } + + const returnStmts = ASTUtils.getNestedReturnStatements( + referencedCallExpression, + ) + + for (const stmt of returnStmts) { + if (stmt.argument === null) { + return + } + + runCheckOnNode({ + context: context, + callNode: node, + expression: stmt.argument, + messageId: 'noDeprecatedOptions', + }) + } + + return + } + + if (firstArgument.type === AST_NODE_TYPES.Identifier) { + const referencedNode = ASTUtils.getReferencedExpressionByIdentifier( + { + context, + node: firstArgument, + }, + ) + + if (referencedNode?.type === AST_NODE_TYPES.ObjectExpression) { + return runCheckOnNode({ + context: context, + callNode: node, + expression: referencedNode, + messageId: 'noDeprecatedOptions', + }) + } + } + + runCheckOnNode({ + context: context, + callNode: node, + expression: firstArgument, + messageId: 'noDeprecatedOptions', + }) + }, + } + }, + }) + +function runCheckOnNode(params: { + context: Readonly> + callNode: TSESTree.CallExpression + expression: TSESTree.Node + messageId: MessageKey +}) { + const { context, expression, messageId, callNode } = params + + const nodes = new Set([expression, ...callNode.arguments]) + + for (const node of nodes) { + for (const option of DEPRECATED_OPTIONS) { + if (node.type !== AST_NODE_TYPES.ObjectExpression) { + continue + } + + const property = ASTUtils.findPropertyWithIdentifierKey( + node.properties, + option, + ) + if (property) { + context.report({ node: property, messageId, data: { option } }) + } + } + } +} diff --git a/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.test.ts b/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.test.ts new file mode 100644 index 0000000000..249a3ad449 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-deprecated-options/no-deprecated-options.test.ts @@ -0,0 +1,263 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { normalizeIndent } from '../../utils/test-utils' +import { name, rule } from './no-deprecated-options.rule' + +const ruleTester = new ESLintUtils.RuleTester({ + parser: '@typescript-eslint/parser', + settings: {}, +}) + +ruleTester.run(name, rule, { + valid: [ + { + code: normalizeIndent` + useQuery() + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery(); + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery({ queryKey, queryFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const result = useQuery({ queryKey, queryFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { createQuery } from "@tanstack/solid-query"; + const result = createQuery({ queryKey, queryFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { useQuery } from "somewhere-else"; + useQuery(queryKey, queryFn, { enabled }); + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getPosts = async () => Promise.resolve([]); + const postsQuery = { queryKey: ["posts"], queryFn: () => getPosts() }; + const usePosts = () => useQuery(postsQuery); + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => ({ queryKey: ['foo'], queryFn: () => Promise.resolve(5) }) + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + const queryKey = () => ['foo']; + const queryFn = () => { + return Promise.resolve(5); + } + return { queryKey, queryFn }; + } + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + try { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } finally { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } + } + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + useMutation() + `, + }, + { + code: normalizeIndent` + import { useMutation } from "@tanstack/react-query"; + useMutation(); + `, + }, + { + code: normalizeIndent` + import { useMutation } from "@tanstack/react-query"; + useMutation({ mutationKey, mutationFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { useMutation } from "@tanstack/react-query"; + const result = useMutation({ mutationKey, mutationFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { createMutation } from "@tanstack/solid-query"; + const result = createMutation({ mutationKey, mutationFn, enabled }); + `, + }, + { + code: normalizeIndent` + import { useMutation } from "somewhere-else"; + useMutation(mutationKey, mutationFn, { enabled }); + `, + }, + { + code: normalizeIndent` + import { useMutation } from "@tanstack/react-query"; + const getPosts = async () => Promise.resolve([]); + const postsQuery = { mutationKey: ["posts"], mutationFn: () => getPosts() }; + const usePosts = () => useMutation(postsQuery); + `, + }, + ], + + invalid: [ + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery({ queryKey, queryFn, enabled, onSuccess: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery({ queryKey, queryFn, enabled, onError: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery({ queryKey, queryFn, enabled, onSettled: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery({ queryKey, queryFn, enabled, isDataEqual: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery(queryKey, queryFn, { enabled, onSuccess: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => ({ queryKey: ['foo'], onSuccess: () => {} }) + useQuery(getQuery()) + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery(['data'], () => fetchData(), { enabled: false, onSuccess: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + useQuery(queryKey, { queryFn, enabled, onSuccess: () => {} }); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x) => { + try { + return { queryKey: "foo", queryFn: () => Promise.resolve(1), onSuccess: () => {} }; + } catch (e) { + if (x > 1) { + return { queryKey: "bar", queryFn: () => Promise.resolve(2) }; + } else { + return null; + } + } + }; + useQuery(getQuery(x)); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x) => { + switch (x) { + case 1: + return { queryKey: "foo", queryFn: () => Promise.resolve(1), onSuccess: () => {} }; + default: + return null; + } + }; + useQuery(getQuery(x)); + `, + errors: [{ messageId: 'noDeprecatedOptions' }], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x, y) => { + if (x) { + return { queryKey: "foo", queryFn: () => Promise.resolve(1), onSuccess: () => {}, onError: () => {} }; + } else { + if (y) { + return { queryKey: "bar", queryFn: () => Promise.resolve(2) }; + } else { + return () => Promise.resolve(3); + } + } + }; + useQuery(getQuery(x)); + `, + errors: [ + { + messageId: 'noDeprecatedOptions', + data: { option: 'onSuccess' }, + }, + { + messageId: 'noDeprecatedOptions', + data: { option: 'onError' }, + }, + ], + }, + ], +})