Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c1b204a
feat: add TanStack Start agent skill with routing, server functions, …
cursoragent Apr 13, 2026
e9ccd2c
feat(react-doctor): add TanStack Start oxlint rules
cursoragent Apr 14, 2026
34b7828
feat(react-doctor): add 5 more TanStack Start rules for exhaustive co…
cursoragent Apr 14, 2026
aa04fda
fix(react-doctor): fix 3 bugs, resolve AGENTS.md violations, upgrade oxc
cursoragent Apr 14, 2026
c33e66f
test(react-doctor): add TanStack Start rule test fixtures and 13 test…
cursoragent Apr 14, 2026
65fcd73
feat(react-doctor): add 6 TanStack Query rules with tests
cursoragent Apr 14, 2026
fd09b45
feat(react-doctor): add 2 new rules from Vercel React Best Practices …
cursoragent Apr 14, 2026
b2b5f30
fix(react-doctor): fix argument index bug for createRootRoute/createR…
cursoragent Apr 14, 2026
72223fc
fix(react-doctor): fix 3 edge case bugs found during rule validation
cursoragent Apr 14, 2026
f6f23e1
refactor(react-doctor): fix AGENTS.md violations from code review
cursoragent Apr 14, 2026
9747794
fix(react-doctor): address remaining Bugbot findings
cursoragent Apr 14, 2026
89f4129
fix(react-doctor): add missing VariableDeclarator:exit in queryStable…
cursoragent Apr 14, 2026
0de8ba9
fix(react-doctor): fix redirect-in-catch false positive and method-or…
cursoragent Apr 14, 2026
40e65d2
fix(react-doctor): fix jsFlatmapFilter identity check and dedup with …
cursoragent Apr 14, 2026
ac1928f
fix(react-doctor): narrow jsCombineIterations exclusion, skip type=mo…
cursoragent Apr 14, 2026
7e2b704
refactor(react-doctor): remove dead hasMutationCall block in tanstack…
cursoragent Apr 15, 2026
1409ce8
fix(react-doctor): fix loader parallel fetch crossing function bounda…
cursoragent Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions packages/react-doctor/src/oxlint-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ const REACT_NATIVE_RULES: Record<string, string> = {
"react-doctor/rn-no-single-element-style-array": "warn",
};

const TANSTACK_START_RULES: Record<string, string> = {
"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<string, string> = {
"react-hooks-js/set-state-in-render": "error",
"react-hooks-js/immutability": "error",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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 : {}),
},
});
59 changes: 59 additions & 0 deletions packages/react-doctor/src/plugin/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
50 changes: 50 additions & 0 deletions packages/react-doctor/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
jsMinMaxLoop,
jsSetMapLookups,
jsTosortedImmutable,
jsFlatmapFilter,
} from "./rules/js-performance.js";
import {
nextjsAsyncClientComponent,
Expand Down Expand Up @@ -58,6 +59,7 @@ import {
renderingAnimateSvgWrapper,
noInlinePropOnMemoComponent,
renderingHydrationNoFlicker,
renderingScriptDeferAsync,
rerenderMemoWithDefaultValue,
} from "./rules/performance.js";
import {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
};

Expand Down
57 changes: 57 additions & 0 deletions packages/react-doctor/src/plugin/rules/js-performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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",
});
},
}),
};
Comment thread
cursor[bot] marked this conversation as resolved.
45 changes: 45 additions & 0 deletions packages/react-doctor/src/plugin/rules/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
"<script src> without defer or async — blocks HTML parsing and delays First Contentful Paint. Add defer for DOM-dependent scripts or async for independent ones",
});
}
},
}),
};
Comment thread
aidenybai marked this conversation as resolved.
Loading
Loading