Skip to content

Commit 0eb635a

Browse files
authored
feat(react-doctor): add TanStack Start rules (#124)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
1 parent 87d4b86 commit 0eb635a

23 files changed

+1878
-174
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
"devDependencies": {
3030
"@changesets/cli": "^2.30.0",
3131
"eslint-plugin-react-hooks": "^7.0.1",
32-
"oxfmt": "^0.44.0",
33-
"oxlint": "^1.59.0",
32+
"oxfmt": "^0.45.0",
33+
"oxlint": "^1.60.0",
3434
"tsdown": "^0.21.7",
3535
"typescript": "^6.0.2",
3636
"vitest": "^4.1.4"

packages/react-doctor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"eslint-plugin-react-hooks": "^7.0.1",
5353
"knip": "^6.3.1",
5454
"ora": "^9.3.0",
55-
"oxlint": "^1.59.0",
55+
"oxlint": "^1.60.0",
5656
"picocolors": "^1.1.1",
5757
"prompts": "^2.4.2",
5858
"typescript": ">=5.0.4 <7"

packages/react-doctor/src/oxlint-config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ const REACT_NATIVE_RULES: Record<string, string> = {
3333
"react-doctor/rn-no-single-element-style-array": "warn",
3434
};
3535

36+
const TANSTACK_START_RULES: Record<string, string> = {
37+
"react-doctor/tanstack-start-route-property-order": "error",
38+
"react-doctor/tanstack-start-no-direct-fetch-in-loader": "warn",
39+
"react-doctor/tanstack-start-server-fn-validate-input": "warn",
40+
"react-doctor/tanstack-start-no-useeffect-fetch": "warn",
41+
"react-doctor/tanstack-start-missing-head-content": "warn",
42+
"react-doctor/tanstack-start-no-anchor-element": "warn",
43+
"react-doctor/tanstack-start-server-fn-method-order": "error",
44+
"react-doctor/tanstack-start-no-navigate-in-render": "warn",
45+
"react-doctor/tanstack-start-no-dynamic-server-fn-import": "error",
46+
"react-doctor/tanstack-start-no-use-server-in-handler": "error",
47+
"react-doctor/tanstack-start-no-secrets-in-loader": "error",
48+
"react-doctor/tanstack-start-get-mutation": "warn",
49+
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
50+
"react-doctor/tanstack-start-loader-parallel-fetch": "warn",
51+
};
52+
3653
const REACT_COMPILER_RULES: Record<string, string> = {
3754
"react-hooks-js/set-state-in-render": "error",
3855
"react-hooks-js/immutability": "error",
@@ -138,6 +155,7 @@ export const createOxlintConfig = ({
138155
"react-doctor/rendering-animate-svg-wrapper": "warn",
139156
"react-doctor/no-inline-prop-on-memo-component": "warn",
140157
"react-doctor/rendering-hydration-no-flicker": "warn",
158+
"react-doctor/rendering-script-defer-async": "warn",
141159

142160
"react-doctor/no-transition-all": "warn",
143161
"react-doctor/no-global-css-variable-animation": "error",
@@ -147,6 +165,8 @@ export const createOxlintConfig = ({
147165

148166
"react-doctor/no-secrets-in-client-code": "error",
149167

168+
"react-doctor/js-flatmap-filter": "warn",
169+
150170
"react-doctor/no-barrel-import": "warn",
151171
"react-doctor/no-full-lodash-import": "warn",
152172
"react-doctor/no-moment": "warn",
@@ -163,8 +183,16 @@ export const createOxlintConfig = ({
163183

164184
"react-doctor/client-passive-event-listeners": "warn",
165185

186+
"react-doctor/query-stable-query-client": "error",
187+
"react-doctor/query-no-rest-destructuring": "warn",
188+
"react-doctor/query-no-void-query-fn": "warn",
189+
"react-doctor/query-no-query-in-effect": "warn",
190+
"react-doctor/query-mutation-missing-invalidation": "warn",
191+
"react-doctor/query-no-usequery-for-mutation": "warn",
192+
166193
"react-doctor/async-parallel": "warn",
167194
...(framework === "nextjs" ? NEXTJS_RULES : {}),
168195
...(framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}),
196+
...(framework === "tanstack-start" ? TANSTACK_START_RULES : {}),
169197
},
170198
});

packages/react-doctor/src/plugin/constants.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,65 @@ export const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
172172

173173
export const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/;
174174

175+
export const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
176+
export const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
177+
178+
export const TANSTACK_ROUTE_PROPERTY_ORDER = [
179+
"params",
180+
"validateSearch",
181+
"loaderDeps",
182+
"search.middlewares",
183+
"ssr",
184+
"context",
185+
"beforeLoad",
186+
"loader",
187+
"onEnter",
188+
"onStay",
189+
"onLeave",
190+
"head",
191+
"scripts",
192+
"headers",
193+
"remountDeps",
194+
];
195+
196+
export const TANSTACK_ROUTE_CREATION_FUNCTIONS = new Set([
197+
"createFileRoute",
198+
"createRoute",
199+
"createRootRoute",
200+
"createRootRouteWithContext",
201+
]);
202+
203+
export const TANSTACK_SERVER_FN_NAMES = new Set(["createServerFn"]);
204+
205+
export const TANSTACK_MIDDLEWARE_METHOD_ORDER = [
206+
"middleware",
207+
"inputValidator",
208+
"client",
209+
"server",
210+
"handler",
211+
];
212+
213+
export const TANSTACK_REDIRECT_FUNCTIONS = new Set(["redirect", "notFound"]);
214+
215+
export const TANSTACK_SERVER_FN_FILE_PATTERN = /\.functions(\.[jt]sx?)?$/;
216+
217+
export const SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER = 2;
218+
219+
export const TANSTACK_QUERY_HOOKS = new Set([
220+
"useQuery",
221+
"useInfiniteQuery",
222+
"useSuspenseQuery",
223+
"useSuspenseInfiniteQuery",
224+
]);
225+
226+
export const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
227+
228+
export const TANSTACK_QUERY_CLIENT_CLASS = "QueryClient";
229+
230+
export const STABLE_HOOK_WRAPPERS = new Set(["useState", "useMemo", "useRef"]);
231+
232+
export const SCRIPT_LOADING_ATTRIBUTES = new Set(["defer", "async"]);
233+
175234
export const GENERIC_EVENT_SUFFIXES = new Set(["Click", "Change", "Input", "Blur", "Focus"]);
176235

177236
export const TRIVIAL_INITIALIZER_NAMES = new Set([

packages/react-doctor/src/plugin/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
jsMinMaxLoop,
2929
jsSetMapLookups,
3030
jsTosortedImmutable,
31+
jsFlatmapFilter,
3132
} from "./rules/js-performance.js";
3233
import {
3334
nextjsAsyncClientComponent,
@@ -58,6 +59,7 @@ import {
5859
renderingAnimateSvgWrapper,
5960
noInlinePropOnMemoComponent,
6061
renderingHydrationNoFlicker,
62+
renderingScriptDeferAsync,
6163
rerenderMemoWithDefaultValue,
6264
} from "./rules/performance.js";
6365
import {
@@ -70,8 +72,32 @@ import {
7072
rnPreferReanimated,
7173
rnNoSingleElementStyleArray,
7274
} from "./rules/react-native.js";
75+
import {
76+
queryMutationMissingInvalidation,
77+
queryNoQueryInEffect,
78+
queryNoRestDestructuring,
79+
queryNoUseQueryForMutation,
80+
queryNoVoidQueryFn,
81+
queryStableQueryClient,
82+
} from "./rules/tanstack-query.js";
7383
import { noEval, noSecretsInClientCode } from "./rules/security.js";
7484
import { serverAfterNonblocking, serverAuthActions } from "./rules/server.js";
85+
import {
86+
tanstackStartGetMutation,
87+
tanstackStartLoaderParallelFetch,
88+
tanstackStartMissingHeadContent,
89+
tanstackStartNoAnchorElement,
90+
tanstackStartNoDirectFetchInLoader,
91+
tanstackStartNoDynamicServerFnImport,
92+
tanstackStartNoNavigateInRender,
93+
tanstackStartNoSecretsInLoader,
94+
tanstackStartNoUseEffectFetch,
95+
tanstackStartNoUseServerInHandler,
96+
tanstackStartRedirectInTryCatch,
97+
tanstackStartRoutePropertyOrder,
98+
tanstackStartServerFnMethodOrder,
99+
tanstackStartServerFnValidateInput,
100+
} from "./rules/tanstack-start.js";
75101
import {
76102
noCascadingSetState,
77103
noDerivedStateEffect,
@@ -108,6 +134,7 @@ const plugin: RulePlugin = {
108134
"rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
109135
"no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
110136
"rendering-hydration-no-flicker": renderingHydrationNoFlicker,
137+
"rendering-script-defer-async": renderingScriptDeferAsync,
111138

112139
"no-transition-all": noTransitionAll,
113140
"no-global-css-variable-animation": noGlobalCssVariableAnimation,
@@ -160,6 +187,7 @@ const plugin: RulePlugin = {
160187
"js-index-maps": jsIndexMaps,
161188
"js-cache-storage": jsCacheStorage,
162189
"js-early-exit": jsEarlyExit,
190+
"js-flatmap-filter": jsFlatmapFilter,
163191
"async-parallel": asyncParallel,
164192

165193
"rn-no-raw-text": rnNoRawText,
@@ -170,6 +198,28 @@ const plugin: RulePlugin = {
170198
"rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
171199
"rn-prefer-reanimated": rnPreferReanimated,
172200
"rn-no-single-element-style-array": rnNoSingleElementStyleArray,
201+
202+
"tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
203+
"tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
204+
"tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,
205+
"tanstack-start-no-useeffect-fetch": tanstackStartNoUseEffectFetch,
206+
"tanstack-start-missing-head-content": tanstackStartMissingHeadContent,
207+
"tanstack-start-no-anchor-element": tanstackStartNoAnchorElement,
208+
"tanstack-start-server-fn-method-order": tanstackStartServerFnMethodOrder,
209+
"tanstack-start-no-navigate-in-render": tanstackStartNoNavigateInRender,
210+
"tanstack-start-no-dynamic-server-fn-import": tanstackStartNoDynamicServerFnImport,
211+
"tanstack-start-no-use-server-in-handler": tanstackStartNoUseServerInHandler,
212+
"tanstack-start-no-secrets-in-loader": tanstackStartNoSecretsInLoader,
213+
"tanstack-start-get-mutation": tanstackStartGetMutation,
214+
"tanstack-start-redirect-in-try-catch": tanstackStartRedirectInTryCatch,
215+
"tanstack-start-loader-parallel-fetch": tanstackStartLoaderParallelFetch,
216+
217+
"query-stable-query-client": queryStableQueryClient,
218+
"query-no-rest-destructuring": queryNoRestDestructuring,
219+
"query-no-void-query-fn": queryNoVoidQueryFn,
220+
"query-no-query-in-effect": queryNoQueryInEffect,
221+
"query-mutation-missing-invalidation": queryMutationMissingInvalidation,
222+
"query-no-usequery-for-mutation": queryNoUseQueryForMutation,
173223
},
174224
};
175225

packages/react-doctor/src/plugin/rules/js-performance.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ export const jsCombineIterations: Rule = {
2929
const innerMethod = innerCall.callee.property.name;
3030
if (!CHAINABLE_ITERATION_METHODS.has(innerMethod)) return;
3131

32+
if (innerMethod === "map" && outerMethod === "filter") {
33+
const filterArgument = node.arguments?.[0];
34+
const isBooleanOrIdentityFilter =
35+
(filterArgument?.type === "Identifier" && filterArgument.name === "Boolean") ||
36+
(filterArgument?.type === "ArrowFunctionExpression" &&
37+
filterArgument.params?.length === 1 &&
38+
filterArgument.body?.type === "Identifier" &&
39+
filterArgument.params[0]?.type === "Identifier" &&
40+
filterArgument.body.name === filterArgument.params[0].name);
41+
if (isBooleanOrIdentityFilter) return;
42+
}
43+
3244
context.report({
3345
node,
3446
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
279291
message: `${statements.length} sequential await statements that appear independent — use Promise.all() for parallel execution`,
280292
});
281293
};
294+
295+
export const jsFlatmapFilter: Rule = {
296+
create: (context: RuleContext) => ({
297+
CallExpression(node: EsTreeNode) {
298+
if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier")
299+
return;
300+
301+
const outerMethod = node.callee.property.name;
302+
if (outerMethod !== "filter") return;
303+
304+
const filterArgument = node.arguments?.[0];
305+
if (!filterArgument) return;
306+
307+
const isIdentityArrow =
308+
filterArgument.type === "ArrowFunctionExpression" &&
309+
filterArgument.params?.length === 1 &&
310+
filterArgument.body?.type === "Identifier" &&
311+
filterArgument.params[0]?.type === "Identifier" &&
312+
filterArgument.body.name === filterArgument.params[0].name;
313+
314+
const isFilterBoolean =
315+
(filterArgument.type === "Identifier" && filterArgument.name === "Boolean") ||
316+
isIdentityArrow;
317+
318+
if (!isFilterBoolean) return;
319+
320+
const innerCall = node.callee.object;
321+
if (
322+
innerCall?.type !== "CallExpression" ||
323+
innerCall.callee?.type !== "MemberExpression" ||
324+
innerCall.callee.property?.type !== "Identifier"
325+
)
326+
return;
327+
328+
const innerMethod = innerCall.callee.property.name;
329+
if (innerMethod !== "map") return;
330+
331+
context.report({
332+
node,
333+
message:
334+
".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass",
335+
});
336+
},
337+
}),
338+
};

packages/react-doctor/src/plugin/rules/performance.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import {
22
ANIMATION_CALLBACK_NAMES,
33
BLUR_VALUE_PATTERN,
44
EFFECT_HOOK_NAMES,
5+
EXECUTABLE_SCRIPT_TYPES,
56
LARGE_BLUR_THRESHOLD_PX,
67
LAYOUT_PROPERTIES,
78
LOADING_STATE_PATTERN,
89
MOTION_ANIMATE_PROPS,
10+
SCRIPT_LOADING_ATTRIBUTES,
911
} from "../constants.js";
1012
import {
1113
getEffectCallback,
@@ -422,3 +424,46 @@ export const renderingHydrationNoFlicker: Rule = {
422424
},
423425
}),
424426
};
427+
428+
export const renderingScriptDeferAsync: Rule = {
429+
create: (context: RuleContext) => ({
430+
JSXOpeningElement(node: EsTreeNode) {
431+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
432+
433+
const attributes = node.attributes ?? [];
434+
const hasSrc = attributes.some(
435+
(attr: EsTreeNode) =>
436+
attr.type === "JSXAttribute" &&
437+
attr.name?.type === "JSXIdentifier" &&
438+
attr.name.name === "src",
439+
);
440+
441+
if (!hasSrc) return;
442+
443+
const typeAttribute = attributes.find(
444+
(attr: EsTreeNode) =>
445+
attr.type === "JSXAttribute" &&
446+
attr.name?.type === "JSXIdentifier" &&
447+
attr.name.name === "type",
448+
);
449+
const typeValue = typeAttribute?.value?.type === "Literal" ? typeAttribute.value.value : null;
450+
if (typeof typeValue === "string" && !EXECUTABLE_SCRIPT_TYPES.has(typeValue)) return;
451+
if (typeValue === "module") return;
452+
453+
const hasLoadingStrategy = attributes.some(
454+
(attr: EsTreeNode) =>
455+
attr.type === "JSXAttribute" &&
456+
attr.name?.type === "JSXIdentifier" &&
457+
SCRIPT_LOADING_ATTRIBUTES.has(attr.name.name),
458+
);
459+
460+
if (!hasLoadingStrategy) {
461+
context.report({
462+
node,
463+
message:
464+
"<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",
465+
});
466+
}
467+
},
468+
}),
469+
};

0 commit comments

Comments
 (0)