diff --git a/package.json b/package.json index 5caba4d..b6b8e7e 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "eslint-plugin-react-hooks": "^7.0.1", - "oxfmt": "^0.44.0", - "oxlint": "^1.59.0", + "oxfmt": "^0.45.0", + "oxlint": "^1.60.0", "tsdown": "^0.21.7", "typescript": "^6.0.2", "vitest": "^4.1.4" diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 4558c9e..ad38630 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -52,7 +52,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "knip": "^6.3.1", "ora": "^9.3.0", - "oxlint": "^1.59.0", + "oxlint": "^1.60.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7" diff --git a/packages/react-doctor/src/oxlint-config.ts b/packages/react-doctor/src/oxlint-config.ts index e85d2ec..b63819c 100644 --- a/packages/react-doctor/src/oxlint-config.ts +++ b/packages/react-doctor/src/oxlint-config.ts @@ -33,6 +33,23 @@ const REACT_NATIVE_RULES: Record = { "react-doctor/rn-no-single-element-style-array": "warn", }; +const TANSTACK_START_RULES: Record = { + "react-doctor/tanstack-start-route-property-order": "error", + "react-doctor/tanstack-start-no-direct-fetch-in-loader": "warn", + "react-doctor/tanstack-start-server-fn-validate-input": "warn", + "react-doctor/tanstack-start-no-useeffect-fetch": "warn", + "react-doctor/tanstack-start-missing-head-content": "warn", + "react-doctor/tanstack-start-no-anchor-element": "warn", + "react-doctor/tanstack-start-server-fn-method-order": "error", + "react-doctor/tanstack-start-no-navigate-in-render": "warn", + "react-doctor/tanstack-start-no-dynamic-server-fn-import": "error", + "react-doctor/tanstack-start-no-use-server-in-handler": "error", + "react-doctor/tanstack-start-no-secrets-in-loader": "error", + "react-doctor/tanstack-start-get-mutation": "warn", + "react-doctor/tanstack-start-redirect-in-try-catch": "warn", + "react-doctor/tanstack-start-loader-parallel-fetch": "warn", +}; + const REACT_COMPILER_RULES: Record = { "react-hooks-js/set-state-in-render": "error", "react-hooks-js/immutability": "error", @@ -138,6 +155,7 @@ export const createOxlintConfig = ({ "react-doctor/rendering-animate-svg-wrapper": "warn", "react-doctor/no-inline-prop-on-memo-component": "warn", "react-doctor/rendering-hydration-no-flicker": "warn", + "react-doctor/rendering-script-defer-async": "warn", "react-doctor/no-transition-all": "warn", "react-doctor/no-global-css-variable-animation": "error", @@ -147,6 +165,8 @@ export const createOxlintConfig = ({ "react-doctor/no-secrets-in-client-code": "error", + "react-doctor/js-flatmap-filter": "warn", + "react-doctor/no-barrel-import": "warn", "react-doctor/no-full-lodash-import": "warn", "react-doctor/no-moment": "warn", @@ -163,8 +183,16 @@ export const createOxlintConfig = ({ "react-doctor/client-passive-event-listeners": "warn", + "react-doctor/query-stable-query-client": "error", + "react-doctor/query-no-rest-destructuring": "warn", + "react-doctor/query-no-void-query-fn": "warn", + "react-doctor/query-no-query-in-effect": "warn", + "react-doctor/query-mutation-missing-invalidation": "warn", + "react-doctor/query-no-usequery-for-mutation": "warn", + "react-doctor/async-parallel": "warn", ...(framework === "nextjs" ? NEXTJS_RULES : {}), ...(framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}), + ...(framework === "tanstack-start" ? TANSTACK_START_RULES : {}), }, }); diff --git a/packages/react-doctor/src/plugin/constants.ts b/packages/react-doctor/src/plugin/constants.ts index e4b954c..7214c5f 100644 --- a/packages/react-doctor/src/plugin/constants.ts +++ b/packages/react-doctor/src/plugin/constants.ts @@ -172,6 +172,65 @@ export const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([ export const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/; +export const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//; +export const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/; + +export const TANSTACK_ROUTE_PROPERTY_ORDER = [ + "params", + "validateSearch", + "loaderDeps", + "search.middlewares", + "ssr", + "context", + "beforeLoad", + "loader", + "onEnter", + "onStay", + "onLeave", + "head", + "scripts", + "headers", + "remountDeps", +]; + +export const TANSTACK_ROUTE_CREATION_FUNCTIONS = new Set([ + "createFileRoute", + "createRoute", + "createRootRoute", + "createRootRouteWithContext", +]); + +export const TANSTACK_SERVER_FN_NAMES = new Set(["createServerFn"]); + +export const TANSTACK_MIDDLEWARE_METHOD_ORDER = [ + "middleware", + "inputValidator", + "client", + "server", + "handler", +]; + +export const TANSTACK_REDIRECT_FUNCTIONS = new Set(["redirect", "notFound"]); + +export const TANSTACK_SERVER_FN_FILE_PATTERN = /\.functions(\.[jt]sx?)?$/; + +export const SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER = 2; + +export const TANSTACK_QUERY_HOOKS = new Set([ + "useQuery", + "useInfiniteQuery", + "useSuspenseQuery", + "useSuspenseInfiniteQuery", +]); + +export const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]); + +export const TANSTACK_QUERY_CLIENT_CLASS = "QueryClient"; + +export const STABLE_HOOK_WRAPPERS = new Set(["useState", "useMemo", "useRef"]); + +export const SCRIPT_LOADING_ATTRIBUTES = new Set(["defer", "async"]); + export const GENERIC_EVENT_SUFFIXES = new Set(["Click", "Change", "Input", "Blur", "Focus"]); export const TRIVIAL_INITIALIZER_NAMES = new Set([ diff --git a/packages/react-doctor/src/plugin/index.ts b/packages/react-doctor/src/plugin/index.ts index 76220bf..abd5fb5 100644 --- a/packages/react-doctor/src/plugin/index.ts +++ b/packages/react-doctor/src/plugin/index.ts @@ -28,6 +28,7 @@ import { jsMinMaxLoop, jsSetMapLookups, jsTosortedImmutable, + jsFlatmapFilter, } from "./rules/js-performance.js"; import { nextjsAsyncClientComponent, @@ -58,6 +59,7 @@ import { renderingAnimateSvgWrapper, noInlinePropOnMemoComponent, renderingHydrationNoFlicker, + renderingScriptDeferAsync, rerenderMemoWithDefaultValue, } from "./rules/performance.js"; import { @@ -70,8 +72,32 @@ import { rnPreferReanimated, rnNoSingleElementStyleArray, } from "./rules/react-native.js"; +import { + queryMutationMissingInvalidation, + queryNoQueryInEffect, + queryNoRestDestructuring, + queryNoUseQueryForMutation, + queryNoVoidQueryFn, + queryStableQueryClient, +} from "./rules/tanstack-query.js"; import { noEval, noSecretsInClientCode } from "./rules/security.js"; import { serverAfterNonblocking, serverAuthActions } from "./rules/server.js"; +import { + tanstackStartGetMutation, + tanstackStartLoaderParallelFetch, + tanstackStartMissingHeadContent, + tanstackStartNoAnchorElement, + tanstackStartNoDirectFetchInLoader, + tanstackStartNoDynamicServerFnImport, + tanstackStartNoNavigateInRender, + tanstackStartNoSecretsInLoader, + tanstackStartNoUseEffectFetch, + tanstackStartNoUseServerInHandler, + tanstackStartRedirectInTryCatch, + tanstackStartRoutePropertyOrder, + tanstackStartServerFnMethodOrder, + tanstackStartServerFnValidateInput, +} from "./rules/tanstack-start.js"; import { noCascadingSetState, noDerivedStateEffect, @@ -108,6 +134,7 @@ const plugin: RulePlugin = { "rendering-animate-svg-wrapper": renderingAnimateSvgWrapper, "no-inline-prop-on-memo-component": noInlinePropOnMemoComponent, "rendering-hydration-no-flicker": renderingHydrationNoFlicker, + "rendering-script-defer-async": renderingScriptDeferAsync, "no-transition-all": noTransitionAll, "no-global-css-variable-animation": noGlobalCssVariableAnimation, @@ -160,6 +187,7 @@ const plugin: RulePlugin = { "js-index-maps": jsIndexMaps, "js-cache-storage": jsCacheStorage, "js-early-exit": jsEarlyExit, + "js-flatmap-filter": jsFlatmapFilter, "async-parallel": asyncParallel, "rn-no-raw-text": rnNoRawText, @@ -170,6 +198,28 @@ const plugin: RulePlugin = { "rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles, "rn-prefer-reanimated": rnPreferReanimated, "rn-no-single-element-style-array": rnNoSingleElementStyleArray, + + "tanstack-start-route-property-order": tanstackStartRoutePropertyOrder, + "tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader, + "tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput, + "tanstack-start-no-useeffect-fetch": tanstackStartNoUseEffectFetch, + "tanstack-start-missing-head-content": tanstackStartMissingHeadContent, + "tanstack-start-no-anchor-element": tanstackStartNoAnchorElement, + "tanstack-start-server-fn-method-order": tanstackStartServerFnMethodOrder, + "tanstack-start-no-navigate-in-render": tanstackStartNoNavigateInRender, + "tanstack-start-no-dynamic-server-fn-import": tanstackStartNoDynamicServerFnImport, + "tanstack-start-no-use-server-in-handler": tanstackStartNoUseServerInHandler, + "tanstack-start-no-secrets-in-loader": tanstackStartNoSecretsInLoader, + "tanstack-start-get-mutation": tanstackStartGetMutation, + "tanstack-start-redirect-in-try-catch": tanstackStartRedirectInTryCatch, + "tanstack-start-loader-parallel-fetch": tanstackStartLoaderParallelFetch, + + "query-stable-query-client": queryStableQueryClient, + "query-no-rest-destructuring": queryNoRestDestructuring, + "query-no-void-query-fn": queryNoVoidQueryFn, + "query-no-query-in-effect": queryNoQueryInEffect, + "query-mutation-missing-invalidation": queryMutationMissingInvalidation, + "query-no-usequery-for-mutation": queryNoUseQueryForMutation, }, }; diff --git a/packages/react-doctor/src/plugin/rules/js-performance.ts b/packages/react-doctor/src/plugin/rules/js-performance.ts index 502d9e8..17bbdc0 100644 --- a/packages/react-doctor/src/plugin/rules/js-performance.ts +++ b/packages/react-doctor/src/plugin/rules/js-performance.ts @@ -29,6 +29,18 @@ export const jsCombineIterations: Rule = { const innerMethod = innerCall.callee.property.name; if (!CHAINABLE_ITERATION_METHODS.has(innerMethod)) return; + if (innerMethod === "map" && outerMethod === "filter") { + const filterArgument = node.arguments?.[0]; + const isBooleanOrIdentityFilter = + (filterArgument?.type === "Identifier" && filterArgument.name === "Boolean") || + (filterArgument?.type === "ArrowFunctionExpression" && + filterArgument.params?.length === 1 && + filterArgument.body?.type === "Identifier" && + filterArgument.params[0]?.type === "Identifier" && + filterArgument.body.name === filterArgument.params[0].name); + if (isBooleanOrIdentityFilter) return; + } + context.report({ node, message: `.${innerMethod}().${outerMethod}() iterates the array twice — combine into a single loop with .reduce() or for...of`, @@ -279,3 +291,48 @@ const reportIfIndependent = (statements: EsTreeNode[], context: RuleContext): vo message: `${statements.length} sequential await statements that appear independent — use Promise.all() for parallel execution`, }); }; + +export const jsFlatmapFilter: Rule = { + create: (context: RuleContext) => ({ + CallExpression(node: EsTreeNode) { + if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier") + return; + + const outerMethod = node.callee.property.name; + if (outerMethod !== "filter") return; + + const filterArgument = node.arguments?.[0]; + if (!filterArgument) return; + + const isIdentityArrow = + filterArgument.type === "ArrowFunctionExpression" && + filterArgument.params?.length === 1 && + filterArgument.body?.type === "Identifier" && + filterArgument.params[0]?.type === "Identifier" && + filterArgument.body.name === filterArgument.params[0].name; + + const isFilterBoolean = + (filterArgument.type === "Identifier" && filterArgument.name === "Boolean") || + isIdentityArrow; + + if (!isFilterBoolean) return; + + const innerCall = node.callee.object; + if ( + innerCall?.type !== "CallExpression" || + innerCall.callee?.type !== "MemberExpression" || + innerCall.callee.property?.type !== "Identifier" + ) + return; + + const innerMethod = innerCall.callee.property.name; + if (innerMethod !== "map") return; + + context.report({ + node, + message: + ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass", + }); + }, + }), +}; diff --git a/packages/react-doctor/src/plugin/rules/performance.ts b/packages/react-doctor/src/plugin/rules/performance.ts index cb4f902..9092a84 100644 --- a/packages/react-doctor/src/plugin/rules/performance.ts +++ b/packages/react-doctor/src/plugin/rules/performance.ts @@ -2,10 +2,12 @@ import { ANIMATION_CALLBACK_NAMES, BLUR_VALUE_PATTERN, EFFECT_HOOK_NAMES, + EXECUTABLE_SCRIPT_TYPES, LARGE_BLUR_THRESHOLD_PX, LAYOUT_PROPERTIES, LOADING_STATE_PATTERN, MOTION_ANIMATE_PROPS, + SCRIPT_LOADING_ATTRIBUTES, } from "../constants.js"; import { getEffectCallback, @@ -422,3 +424,46 @@ export const renderingHydrationNoFlicker: Rule = { }, }), }; + +export const renderingScriptDeferAsync: Rule = { + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNode) { + if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return; + + const attributes = node.attributes ?? []; + const hasSrc = attributes.some( + (attr: EsTreeNode) => + attr.type === "JSXAttribute" && + attr.name?.type === "JSXIdentifier" && + attr.name.name === "src", + ); + + if (!hasSrc) return; + + const typeAttribute = attributes.find( + (attr: EsTreeNode) => + attr.type === "JSXAttribute" && + attr.name?.type === "JSXIdentifier" && + attr.name.name === "type", + ); + const typeValue = typeAttribute?.value?.type === "Literal" ? typeAttribute.value.value : null; + if (typeof typeValue === "string" && !EXECUTABLE_SCRIPT_TYPES.has(typeValue)) return; + if (typeValue === "module") return; + + const hasLoadingStrategy = attributes.some( + (attr: EsTreeNode) => + attr.type === "JSXAttribute" && + attr.name?.type === "JSXIdentifier" && + SCRIPT_LOADING_ATTRIBUTES.has(attr.name.name), + ); + + if (!hasLoadingStrategy) { + context.report({ + node, + message: + "