Skip to content

Commit 4fb4d27

Browse files
fix(react-doctor): forward reactMajorVersion in programmatic diagnose() + cleanups (#174)
* fix(react-doctor): forward reactMajorVersion in programmatic diagnose() + cleanups H1 (regression introduced by #172) — `diagnose()` in `src/index.ts` forgot to forward `reactMajorVersion` to `runOxlint`. After the directional version-gating change in #172, that meant every "prefer-newer-api" rule (today: `prefer-use-effect-event`) was silently skipped for every programmatic API consumer, even on React 19+ projects. The CLI (`scan.ts`) was unaffected because it always passed the version explicitly. Fix is one line + one import — mirror what `scan.ts` already does. Added `tests/diagnose.test.ts` with a regression test that asserts `prefer-use-effect-event` fires on a React 19 fixture, plus a symmetric guard that it stays skipped when the React version can't be parsed (e.g. a github: range). H2 — updated the stale docstring on `runOxlint`'s `reactMajorVersion` field. The doc still claimed "`null` means unknown — leave those rules enabled" but after #172 the null branch is directional (deprecation-warning rules stay on, prefer-newer-api rules go off). M1 — `SUB_HANDLER_DIRECT_CALLEE_NAMES` was just an alias of `TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES`. Both names existed for narrative reasons in different files; knip flagged the duplicate export. Collapsed to the canonical name and updated the one consumer (`isCallExpressionWithSubHandlerCallee` in state-and-effects). L1 — moved `walkInsideStatementBlocks` from inline in `state-and-effects.ts` to `plugin/helpers.ts` next to its sibling `walkAst`. It already had four call sites and is the natural "synchronous-only" walker for any rule asking what runs inside an effect's own body — colocating with `walkAst` makes future rules discover it. L2 (audit finding) — proposed receiver-gating `post`/`put`/`patch` in `EVENT_TRIGGERED_SIDE_EFFECT_CALLEES` INTENTIONALLY NOT TAKEN. The canonical "You Might Not Need an Effect" §6 example is `post(jsonToSubmit)` as a bare callee, so removing those names breaks textbook detection (3 existing tests / fixtures). Documented the trade-off in the constants.ts docstring. Validation - 642 tests passing (640 baseline + 2 new diagnose-API regressions) - typecheck / lint / format clean Co-authored-by: Cursor <cursoragent@cursor.com> * chore: format scripts/benchmark-scores.ts Pre-existing formatting issue introduced in 6afdc04 — the script was committed unformatted, breaking `pnpm format:check` on every PR branched off main since. Picked up by rebasing this PR. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6afdc04 commit 4fb4d27

7 files changed

Lines changed: 135 additions & 41 deletions

File tree

packages/react-doctor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { clearProjectCache, discoverProject } from "./utils/discover-project.js"
2424
import { computeJsxIncludePaths } from "./utils/jsx-include-paths.js";
2525
import { clearConfigCache, loadConfig } from "./utils/load-config.js";
2626
import { mergeAndFilterDiagnostics } from "./utils/merge-and-filter-diagnostics.js";
27+
import { parseReactMajor } from "./utils/parse-react-major.js";
2728
import { clearPackageJsonCache } from "./utils/read-package-json.js";
2829
import { createNodeReadFileLinesSync } from "./utils/read-file-lines-node.js";
2930
import { resolveLintIncludePaths } from "./utils/resolve-lint-include-paths.js";
@@ -130,6 +131,7 @@ export const diagnose = async (
130131
framework: projectInfo.framework,
131132
hasReactCompiler: projectInfo.hasReactCompiler,
132133
hasTanStackQuery: projectInfo.hasTanStackQuery,
134+
reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
133135
includePaths: lintIncludePaths,
134136
customRulesOnly: userConfig?.customRulesOnly ?? false,
135137
respectInlineDisables: effectiveRespectInlineDisables,

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,6 @@ export const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
417417
"queueMicrotask",
418418
]);
419419

420-
export const SUB_HANDLER_DIRECT_CALLEE_NAMES = TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES;
421-
422420
// Timer registrations that ALWAYS need a corresponding cleanup call
423421
// (a stricter subset of the scheduler list above — `requestAnimationFrame`
424422
// and friends already invoke once and self-clean, but `setTimeout` /
@@ -575,6 +573,12 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([
575573
// network/analytics side effects. Detection still works via the
576574
// receiver-bound member-call shape (`analytics.track(...)`,
577575
// `api.del(...)`) in `EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS`.
576+
//
577+
// `post` / `put` / `patch` are KEPT here — the canonical "You Might
578+
// Not Need an Effect" §6 example is `post(jsonToSubmit)` as a bare
579+
// callee, so removing them would silently miss the textbook case.
580+
// The trade-off (FPs on user helpers named `post(...)`) is acceptable
581+
// at this scope.
578582
export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
579583
...FETCH_CALLEE_NAMES,
580584
// Network shorthand verbs (article uses `post`)

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,41 @@ export const walkAst = (node: EsTreeNode, visitor: (child: EsTreeNode) => boolea
5353
}
5454
};
5555

56+
// HACK: variant of `walkAst` that descends through control-flow blocks
57+
// (IfStatement / TryStatement / SwitchCase / loops / labels) but stops
58+
// at any nested function boundary. Used by rules that ask "what runs
59+
// SYNCHRONOUSLY inside this effect's body?" — counts the
60+
// `if (cond) setX(...)` write but ignores the deferred
61+
// `setTimeout(() => setX(...))` one.
62+
//
63+
// Unlike `walkAst`, this one does not support pruning via `false`
64+
// return — descent is always complete except at function boundaries.
65+
export const walkInsideStatementBlocks = (
66+
node: EsTreeNode,
67+
visitor: (child: EsTreeNode) => void,
68+
): void => {
69+
if (!node || typeof node !== "object") return;
70+
if (
71+
node.type === "FunctionDeclaration" ||
72+
node.type === "FunctionExpression" ||
73+
node.type === "ArrowFunctionExpression"
74+
) {
75+
return;
76+
}
77+
visitor(node);
78+
for (const key of Object.keys(node)) {
79+
if (key === "parent") continue;
80+
const child = node[key];
81+
if (Array.isArray(child)) {
82+
for (const item of child) {
83+
if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
84+
}
85+
} else if (child && typeof child === "object" && child.type) {
86+
walkInsideStatementBlocks(child, visitor);
87+
}
88+
}
89+
};
90+
5691
export const isSetterIdentifier = (name: string): boolean => SETTER_PATTERN.test(name);
5792

5893
export const isSetterCall = (node: EsTreeNode): boolean =>

packages/react-doctor/src/plugin/rules/state-and-effects.ts

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import {
1717
NAVIGATION_RECEIVER_NAMES,
1818
REACT_HANDLER_PROP_PATTERN,
1919
RELATED_USE_STATE_THRESHOLD,
20-
SUB_HANDLER_DIRECT_CALLEE_NAMES,
2120
SUBSCRIPTION_METHOD_NAMES,
21+
TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES,
2222
TIMER_CALLEE_NAMES_REQUIRING_CLEANUP,
2323
TIMER_CLEANUP_CALLEE_NAMES,
2424
TRIVIAL_DERIVATION_CALLEE_NAMES,
@@ -41,6 +41,7 @@ import {
4141
isSetterIdentifier,
4242
isUppercaseName,
4343
walkAst,
44+
walkInsideStatementBlocks,
4445
} from "../helpers.js";
4546
import type { EsTreeNode, Rule, RuleContext } from "../types.js";
4647

@@ -688,32 +689,6 @@ const collectReturnExpressions = (componentBody: EsTreeNode): EsTreeNode[] => {
688689
return returns;
689690
};
690691

691-
const walkInsideStatementBlocks = (
692-
node: EsTreeNode,
693-
visitor: (child: EsTreeNode) => void,
694-
): void => {
695-
if (!node || typeof node !== "object") return;
696-
if (
697-
node.type === "FunctionDeclaration" ||
698-
node.type === "FunctionExpression" ||
699-
node.type === "ArrowFunctionExpression"
700-
) {
701-
return;
702-
}
703-
visitor(node);
704-
for (const key of Object.keys(node)) {
705-
if (key === "parent") continue;
706-
const child = node[key];
707-
if (Array.isArray(child)) {
708-
for (const item of child) {
709-
if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
710-
}
711-
} else if (child && typeof child === "object" && child.type) {
712-
walkInsideStatementBlocks(child, visitor);
713-
}
714-
}
715-
};
716-
717692
const collectIdentifierNames = (expression: EsTreeNode): Set<string> => {
718693
const names = new Set<string>();
719694
walkAst(expression, (child: EsTreeNode) => {
@@ -2353,9 +2328,9 @@ export const noMutableInDeps: Rule = {
23532328
// - a destructured prop named `on[A-Z]…`, OR
23542329
// - a local declared via `const F = useCallback(...)`
23552330
// (3) every read of `F` inside the effect body sits inside a sub-
2356-
// handler (SUB_HANDLER_DIRECT_CALLEE_NAMES, OR a MemberExpression
2357-
// whose property is in SUBSCRIPTION_METHOD_NAMES — same set the
2358-
// prefer-use-sync-external-store family uses)
2331+
// handler (TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES, OR a
2332+
// MemberExpression whose property is in SUBSCRIPTION_METHOD_NAMES
2333+
// — same set the prefer-use-sync-external-store family uses)
23592334
// (4) `F` is NEVER read at the effect's own top level
23602335
const collectFunctionTypedLocalBindings = (componentBody: EsTreeNode): Set<string> => {
23612336
const functionTypedLocals = new Set<string>();
@@ -2393,7 +2368,7 @@ const findEnclosingFunctionInsideEffect = (
23932368
const isCallExpressionWithSubHandlerCallee = (callExpression: EsTreeNode): boolean => {
23942369
if (callExpression?.type !== "CallExpression") return false;
23952370
const callee = callExpression.callee;
2396-
if (callee?.type === "Identifier" && SUB_HANDLER_DIRECT_CALLEE_NAMES.has(callee.name)) {
2371+
if (callee?.type === "Identifier" && TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES.has(callee.name)) {
23972372
return true;
23982373
}
23992374
if (

packages/react-doctor/src/utils/run-oxlint.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -848,9 +848,13 @@ interface RunOxlintOptions {
848848
hasTanStackQuery: boolean;
849849
/**
850850
* Major version of React detected for the project. Forwarded to
851-
* `createOxlintConfig` so React-19-deprecation rules only fire on
852-
* projects where they actually apply. `null` means "unknown — leave
853-
* those rules enabled".
851+
* `createOxlintConfig`, which gates rules directionally:
852+
* - `"deprecation-warning"` rules (e.g. `no-default-props`) stay
853+
* enabled when this is `null` so mid-migration projects still
854+
* get the warning even if version detection failed.
855+
* - `"prefer-newer-api"` rules (e.g. `prefer-use-effect-event`) are
856+
* skipped when this is `null` to avoid recommending APIs that
857+
* may not exist in the consumer's React version.
854858
*/
855859
reactMajorVersion?: number | null;
856860
includePaths?: string[];
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterAll, describe, expect, it } from "vite-plus/test";
5+
6+
import { diagnose } from "../src/index.js";
7+
import { setupReactProject } from "./regressions/_helpers.js";
8+
9+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diagnose-api-"));
10+
11+
afterAll(() => {
12+
fs.rmSync(tempRoot, { recursive: true, force: true });
13+
});
14+
15+
describe("diagnose() programmatic API", () => {
16+
// Regression: pre-fix the programmatic `diagnose()` entry forgot to
17+
// forward `reactMajorVersion` to `runOxlint`. After the directional
18+
// version-gating change, that meant every "prefer-newer-api" rule
19+
// (today: `prefer-use-effect-event`) was silently skipped for all
20+
// programmatic API consumers, even on React 19+ projects. The CLI
21+
// entry (`scan.ts`) was unaffected because it always passed the
22+
// version explicitly.
23+
it("emits prefer-use-effect-event diagnostics on a React 19 project (the prefer-newer-api version-gated rule fires)", async () => {
24+
const projectDir = setupReactProject(tempRoot, "diagnose-prefer-use-effect-event-fires", {
25+
files: {
26+
"src/Debounced.tsx": `import { useEffect, useState } from "react";
27+
28+
export const Debounced = ({ onChange }: { onChange: (value: string) => void }) => {
29+
const [text, setText] = useState("");
30+
useEffect(() => {
31+
const id = setTimeout(() => onChange(text), 300);
32+
return () => clearTimeout(id);
33+
}, [text, onChange]);
34+
return <input value={text} onChange={(event) => setText(event.target.value)} />;
35+
};
36+
`,
37+
},
38+
});
39+
40+
const result = await diagnose(projectDir, { lint: true, deadCode: false });
41+
const preferUseEffectEventHits = result.diagnostics.filter(
42+
(diagnostic) => diagnostic.rule === "prefer-use-effect-event",
43+
);
44+
expect(preferUseEffectEventHits.length).toBeGreaterThanOrEqual(1);
45+
});
46+
47+
it("skips prefer-use-effect-event when the project's React version cannot be resolved (no react dep)", async () => {
48+
// Symmetric guard: when the project has no React dependency the
49+
// function throws before lint runs, so we synthesize a project
50+
// with an unresolvable React version range. Its major can't be
51+
// parsed, so `parseReactMajor` returns null and the prefer-newer-
52+
// api rule should be skipped pessimistically — confirming the
53+
// forward really is honoring the version-gate boundary.
54+
const projectDir = setupReactProject(tempRoot, "diagnose-prefer-use-effect-event-skipped", {
55+
reactVersion: "github:facebook/react",
56+
files: {
57+
"src/Debounced.tsx": `import { useEffect, useState } from "react";
58+
59+
export const Debounced = ({ onChange }: { onChange: (value: string) => void }) => {
60+
const [text, setText] = useState("");
61+
useEffect(() => {
62+
const id = setTimeout(() => onChange(text), 300);
63+
return () => clearTimeout(id);
64+
}, [text, onChange]);
65+
return <input value={text} onChange={(event) => setText(event.target.value)} />;
66+
};
67+
`,
68+
},
69+
});
70+
71+
const result = await diagnose(projectDir, { lint: true, deadCode: false });
72+
const preferUseEffectEventHits = result.diagnostics.filter(
73+
(diagnostic) => diagnostic.rule === "prefer-use-effect-event",
74+
);
75+
expect(preferUseEffectEventHits).toHaveLength(0);
76+
});
77+
});

scripts/benchmark-scores.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ interface BenchmarkResult {
3535
errorMessage: string | null;
3636
}
3737

38-
const gh = (args: string): string =>
39-
execSync(`gh ${args}`, { encoding: "utf-8" }).trim();
38+
const gh = (args: string): string => execSync(`gh ${args}`, { encoding: "utf-8" }).trim();
4039

4140
const getLatestRunId = (): number => {
4241
const output = gh(
@@ -157,9 +156,7 @@ const main = async (): Promise<void> => {
157156
const results = await Promise.all(
158157
artifacts.map(({ id }) => downloadAndParseArtifact(id, tempDirectory)),
159158
);
160-
const validResults = results.filter(
161-
(result): result is BenchmarkResult => result !== null,
162-
);
159+
const validResults = results.filter((result): result is BenchmarkResult => result !== null);
163160

164161
if (validResults.length === 0) {
165162
console.error("No valid benchmark results found");

0 commit comments

Comments
 (0)