Skip to content

Commit 2a1b0ae

Browse files
fix(react-doctor): tighten state-and-effects rules against false positives (#172)
Audit pass on `main` after the consolidation/AGENTS.md cleanups (#167, #169). Targets eight specific FP/FN patterns surfaced by re-reading the merged code end-to-end and checking each rule against its own intent docstring. High - H1 unify release detection between `effectHasCleanupRelease` and `cleanupReleasesSubscription`: both now share `isCleanupReturn` / `isReleaseLikeCall` / `containsReleaseLikeCall`. Previously `prefer-use-sync-external-store` and `prefer-use-effect-event` could disagree on whether a cleanup with a generic teardown verb (`cleanup()`, `dispose()`) counted as a release, producing inconsistent diagnostics. - H2 require function-shaped return for `isExternalSyncEffect`: any `return <expr>` previously qualified the effect as "external sync" and silently disabled chain detection, so `return null` / `return 42` / `return condition && cleanup` would mask real chains. We now only treat function-shaped returns (Arrow, FnExpr, Call, Identifier) as cleanup. - H3 directional version gating: `prefer-use-effect-event` is a "prefer-newer-api" rule and was firing on projects where the React major couldn't be detected. Gating now records whether each rule is "prefer-newer-api" (skip when version is unknown) or "deprecation-warning" (keep firing when version is unknown). Medium - M1 receiver-gate ambiguous direct callees: `track`, `logEvent`, `del` removed from `EVENT_TRIGGERED_SIDE_EFFECT_CALLEES` — they were too generic as bare identifiers and produced FPs on user-defined helpers. Receiver-gated member calls (`analytics.track(...)`) still fire. - M2 extend `findSubHandlerForEnclosingFunction` to FunctionDeclaration and AssignmentExpression shapes — `function handler() {}` and `let h; h = (e) => {}` are now recognized alongside `const h = ...`. - M3 deep-walk for `noPropCallbackInEffect`: the rule previously only scanned top-level effect statements and missed the very common `if (didChange) onChange(state)` shape. Walk now descends through control-flow blocks but stops at function boundaries (so deferred sub-handlers like `setTimeout(() => onChange(state))` stay the domain of `prefer-use-effect-event`). - M4 `collectWrittenStateNamesInEffect` no longer counts setter calls inside nested functions for chain detection — deferred writes (`setTimeout(() => setX(...))`) are not synchronous chain triggers. Low - L1 `noMirrorPropEffect` now accepts multi-element deps as long as the mirrored prop's root is in the deps array. The prior "exactly one dep" requirement missed legitimate mirror shapes with an unrelated extra dep. - L2 (paired with H1/H2) `effectHasCleanupRelease` "return Identifier" now narrows to known bound release names so a stray non-function identifier return doesn't silently look like a release. - L3 extend `prop-stack-barrier.test.ts` to cover all four `createComponentPropStackTracker` consumers (`no-derived-useState`, `no-prop-callback-in-effect`, `no-mirror-prop-effect`, `prefer-use-effect-event`) so the empty-frame barrier semantic is regression-tested for every rule that depends on it. - L4 extract a sibling `createComponentBindingStackTracker` and migrate `noEffectEventInDeps` onto it — the third inlined push/pop scaffold was effectively a copy-paste of the prop-stack one specialised to binding sets. Tests - 634 passing (up from 619). New regressions cover each behavior change above plus FP guards (e.g. mirror shape with prop root NOT in deps must not fire, sub-handler reads must not fire `no-prop-callback-in-effect`). - `tests/run-oxlint.test.ts` now passes `reactMajorVersion: 19` for the basic / Next / TanStack Start fixtures so they exercise the prefer-newer-api rules under a known React major. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 36518ba commit 2a1b0ae

7 files changed

Lines changed: 761 additions & 181 deletions

File tree

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -381,24 +381,50 @@ export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet<string> = new Set([
381381

382382
// HACK: single source of truth for which rules are gated behind the
383383
// project's detected React major. Adding a new version-gated rule means
384-
// touching just this map. `null` reactMajorVersion (couldn't detect)
385-
// keeps every rule enabled so we never silently swallow real findings.
386-
const VERSION_GATED_RULE_IDS: ReadonlyMap<string, number> = new Map([
387-
["react-doctor/no-react19-deprecated-apis", REACT_19_DEPRECATION_MIN_MAJOR],
388-
["react-doctor/no-default-props", REACT_19_DEPRECATION_MIN_MAJOR],
389-
["react-doctor/no-react-dom-deprecated-apis", REACT_DOM_LEGACY_API_MIN_MAJOR],
390-
["react-doctor/prefer-use-effect-event", USE_EFFECT_EVENT_MIN_MAJOR],
384+
// touching just this map.
385+
//
386+
// `mode` controls behavior when version detection FAILS (null):
387+
// - "prefer-newer-api": the rule recommends an API that ONLY exists at
388+
// or above `minMajor` (e.g. `useEffectEvent`). Suggesting it on a
389+
// project where we can't prove the API exists is noise — fail closed.
390+
// - "deprecation-warning": the rule flags patterns that BREAK at or
391+
// above `minMajor` (e.g. `defaultProps` removal in React 19). Useful
392+
// even on projects we can't version-detect, because the user may be
393+
// mid-migration. Fail open.
394+
type VersionGateMode = "prefer-newer-api" | "deprecation-warning";
395+
interface VersionGate {
396+
minMajor: number;
397+
mode: VersionGateMode;
398+
}
399+
const VERSION_GATED_RULE_IDS: ReadonlyMap<string, VersionGate> = new Map([
400+
[
401+
"react-doctor/no-react19-deprecated-apis",
402+
{ minMajor: REACT_19_DEPRECATION_MIN_MAJOR, mode: "deprecation-warning" },
403+
],
404+
[
405+
"react-doctor/no-default-props",
406+
{ minMajor: REACT_19_DEPRECATION_MIN_MAJOR, mode: "deprecation-warning" },
407+
],
408+
[
409+
"react-doctor/no-react-dom-deprecated-apis",
410+
{ minMajor: REACT_DOM_LEGACY_API_MIN_MAJOR, mode: "deprecation-warning" },
411+
],
412+
[
413+
"react-doctor/prefer-use-effect-event",
414+
{ minMajor: USE_EFFECT_EVENT_MIN_MAJOR, mode: "prefer-newer-api" },
415+
],
391416
]);
392417

393418
const filterRulesByReactMajor = (
394419
rules: Record<string, RuleSeverity>,
395420
reactMajorVersion: number | null,
396421
): Record<string, RuleSeverity> => {
397-
if (reactMajorVersion === null) return rules;
398422
return Object.fromEntries(
399423
Object.entries(rules).filter(([ruleKey]) => {
400-
const minMajor = VERSION_GATED_RULE_IDS.get(ruleKey);
401-
return minMajor === undefined || reactMajorVersion >= minMajor;
424+
const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
425+
if (gate === undefined) return true;
426+
if (reactMajorVersion === null) return gate.mode === "deprecation-warning";
427+
return reactMajorVersion >= gate.minMajor;
402428
}),
403429
);
404430
};

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,19 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([
568568
// almost always denotes a fire-and-forget user-action effect."
569569
// Layered on top of `FETCH_CALLEE_NAMES` so adding a new HTTP client
570570
// shorthand in one place propagates to every detector that recognizes it.
571+
//
572+
// HACK: ambiguous generic verbs (`track`, `logEvent`, `del`) used to
573+
// live here too. They produced FPs on user-defined helpers
574+
// (`track(progress)`, `del(item)`) that have nothing to do with
575+
// network/analytics side effects. Detection still works via the
576+
// receiver-bound member-call shape (`analytics.track(...)`,
577+
// `api.del(...)`) in `EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS`.
571578
export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
572579
...FETCH_CALLEE_NAMES,
573580
// Network shorthand verbs (article uses `post`)
574581
"post",
575582
"put",
576583
"patch",
577-
"del",
578584
// Navigation
579585
"navigate",
580586
"navigateTo",
@@ -584,8 +590,6 @@ export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
584590
"alert",
585591
"confirm",
586592
// Analytics
587-
"track",
588-
"logEvent",
589593
"logVisit",
590594
"captureEvent",
591595
]);

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ interface ComponentPropStackTracker {
1919
visitors: RuleVisitors;
2020
}
2121

22+
interface ComponentBindingStackTrackerCallbacks {
23+
onVariableDeclarator?: (node: EsTreeNode) => void;
24+
}
25+
26+
interface ComponentBindingStackTracker {
27+
isInsideComponent: () => boolean;
28+
isBoundName: (name: string) => boolean;
29+
addBindingToCurrentFrame: (name: string) => void;
30+
visitors: RuleVisitors;
31+
}
32+
2233
// HACK: AST is acyclic except for `parent` back-references, which we skip.
2334
// Visitors may return `false` to prune the subtree below `node` (e.g. to
2435
// stop walking into nested functions when collecting `await` expressions
@@ -476,3 +487,63 @@ export const createComponentPropStackTracker = (
476487

477488
return { isPropName, getCurrentPropNames, visitors };
478489
};
490+
491+
// HACK: sibling of `createComponentPropStackTracker` for rules that need
492+
// to track *binding* sets per component scope rather than the destructured
493+
// prop set — e.g. `no-effect-event-in-deps` accumulates the names of
494+
// `useEffectEvent` declarators while inside a component and then queries
495+
// "is this dep-array identifier one of our useEffectEvent bindings?".
496+
//
497+
// Three rules previously reimplemented this push/pop bookkeeping inline.
498+
// They now share the same scaffold; the per-rule predicate (e.g. "is the
499+
// initializer a `useEffectEvent(...)` call?") lives in the
500+
// `onVariableDeclarator` callback.
501+
//
502+
// The barrier semantic is intentionally simpler than the prop-stack
503+
// tracker: the rule (e.g. `no-effect-event-in-deps`) only mutates the
504+
// top frame for VariableDeclarators directly inside a component, and
505+
// the stack only grows on FunctionDeclaration / VariableDeclarator
506+
// component entries, so a closed-over name from an outer component
507+
// can't leak in via a nested helper.
508+
export const createComponentBindingStackTracker = (
509+
callbacks?: ComponentBindingStackTrackerCallbacks,
510+
): ComponentBindingStackTracker => {
511+
const componentBindingStack: Array<Set<string>> = [];
512+
513+
const isInsideComponent = (): boolean => componentBindingStack.length > 0;
514+
515+
const isBoundName = (name: string): boolean => {
516+
for (let frameIndex = componentBindingStack.length - 1; frameIndex >= 0; frameIndex--) {
517+
if (componentBindingStack[frameIndex].has(name)) return true;
518+
}
519+
return false;
520+
};
521+
522+
const addBindingToCurrentFrame = (name: string): void => {
523+
if (componentBindingStack.length === 0) return;
524+
componentBindingStack[componentBindingStack.length - 1].add(name);
525+
};
526+
527+
const visitors: RuleVisitors = {
528+
FunctionDeclaration(node: EsTreeNode) {
529+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
530+
componentBindingStack.push(new Set());
531+
},
532+
"FunctionDeclaration:exit"(node: EsTreeNode) {
533+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
534+
componentBindingStack.pop();
535+
},
536+
VariableDeclarator(node: EsTreeNode) {
537+
if (isComponentAssignment(node)) {
538+
componentBindingStack.push(new Set());
539+
return;
540+
}
541+
callbacks?.onVariableDeclarator?.(node);
542+
},
543+
"VariableDeclarator:exit"(node: EsTreeNode) {
544+
if (isComponentAssignment(node)) componentBindingStack.pop();
545+
},
546+
};
547+
548+
return { isInsideComponent, isBoundName, addBindingToCurrentFrame, visitors };
549+
};

0 commit comments

Comments
 (0)