Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 36 additions & 10 deletions packages/react-doctor/src/oxlint-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,24 +381,50 @@ export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet<string> = new Set([

// HACK: single source of truth for which rules are gated behind the
// project's detected React major. Adding a new version-gated rule means
// touching just this map. `null` reactMajorVersion (couldn't detect)
// keeps every rule enabled so we never silently swallow real findings.
const VERSION_GATED_RULE_IDS: ReadonlyMap<string, number> = new Map([
["react-doctor/no-react19-deprecated-apis", REACT_19_DEPRECATION_MIN_MAJOR],
["react-doctor/no-default-props", REACT_19_DEPRECATION_MIN_MAJOR],
["react-doctor/no-react-dom-deprecated-apis", REACT_DOM_LEGACY_API_MIN_MAJOR],
["react-doctor/prefer-use-effect-event", USE_EFFECT_EVENT_MIN_MAJOR],
// touching just this map.
//
// `mode` controls behavior when version detection FAILS (null):
// - "prefer-newer-api": the rule recommends an API that ONLY exists at
// or above `minMajor` (e.g. `useEffectEvent`). Suggesting it on a
// project where we can't prove the API exists is noise — fail closed.
// - "deprecation-warning": the rule flags patterns that BREAK at or
// above `minMajor` (e.g. `defaultProps` removal in React 19). Useful
// even on projects we can't version-detect, because the user may be
// mid-migration. Fail open.
type VersionGateMode = "prefer-newer-api" | "deprecation-warning";
interface VersionGate {
minMajor: number;
mode: VersionGateMode;
}
const VERSION_GATED_RULE_IDS: ReadonlyMap<string, VersionGate> = new Map([
[
"react-doctor/no-react19-deprecated-apis",
{ minMajor: REACT_19_DEPRECATION_MIN_MAJOR, mode: "deprecation-warning" },
],
[
"react-doctor/no-default-props",
{ minMajor: REACT_19_DEPRECATION_MIN_MAJOR, mode: "deprecation-warning" },
],
[
"react-doctor/no-react-dom-deprecated-apis",
{ minMajor: REACT_DOM_LEGACY_API_MIN_MAJOR, mode: "deprecation-warning" },
],
[
"react-doctor/prefer-use-effect-event",
{ minMajor: USE_EFFECT_EVENT_MIN_MAJOR, mode: "prefer-newer-api" },
],
]);

const filterRulesByReactMajor = (
rules: Record<string, RuleSeverity>,
reactMajorVersion: number | null,
): Record<string, RuleSeverity> => {
if (reactMajorVersion === null) return rules;
return Object.fromEntries(
Object.entries(rules).filter(([ruleKey]) => {
const minMajor = VERSION_GATED_RULE_IDS.get(ruleKey);
return minMajor === undefined || reactMajorVersion >= minMajor;
const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
if (gate === undefined) return true;
if (reactMajorVersion === null) return gate.mode === "deprecation-warning";
return reactMajorVersion >= gate.minMajor;
}),
);
};
Expand Down
10 changes: 7 additions & 3 deletions packages/react-doctor/src/plugin/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,13 +568,19 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([
// almost always denotes a fire-and-forget user-action effect."
// Layered on top of `FETCH_CALLEE_NAMES` so adding a new HTTP client
// shorthand in one place propagates to every detector that recognizes it.
//
// HACK: ambiguous generic verbs (`track`, `logEvent`, `del`) used to
// live here too. They produced FPs on user-defined helpers
// (`track(progress)`, `del(item)`) that have nothing to do with
// network/analytics side effects. Detection still works via the
// receiver-bound member-call shape (`analytics.track(...)`,
// `api.del(...)`) in `EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS`.
export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
...FETCH_CALLEE_NAMES,
// Network shorthand verbs (article uses `post`)
"post",
"put",
"patch",
"del",
// Navigation
"navigate",
"navigateTo",
Expand All @@ -584,8 +590,6 @@ export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
"alert",
"confirm",
// Analytics
"track",
"logEvent",
"logVisit",
"captureEvent",
]);
Expand Down
71 changes: 71 additions & 0 deletions packages/react-doctor/src/plugin/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ interface ComponentPropStackTracker {
visitors: RuleVisitors;
}

interface ComponentBindingStackTrackerCallbacks {
onVariableDeclarator?: (node: EsTreeNode) => void;
}

interface ComponentBindingStackTracker {
isInsideComponent: () => boolean;
isBoundName: (name: string) => boolean;
addBindingToCurrentFrame: (name: string) => void;
visitors: RuleVisitors;
}

// HACK: AST is acyclic except for `parent` back-references, which we skip.
// Visitors may return `false` to prune the subtree below `node` (e.g. to
// stop walking into nested functions when collecting `await` expressions
Expand Down Expand Up @@ -476,3 +487,63 @@ export const createComponentPropStackTracker = (

return { isPropName, getCurrentPropNames, visitors };
};

// HACK: sibling of `createComponentPropStackTracker` for rules that need
// to track *binding* sets per component scope rather than the destructured
// prop set — e.g. `no-effect-event-in-deps` accumulates the names of
// `useEffectEvent` declarators while inside a component and then queries
// "is this dep-array identifier one of our useEffectEvent bindings?".
//
// Three rules previously reimplemented this push/pop bookkeeping inline.
// They now share the same scaffold; the per-rule predicate (e.g. "is the
// initializer a `useEffectEvent(...)` call?") lives in the
// `onVariableDeclarator` callback.
//
// The barrier semantic is intentionally simpler than the prop-stack
// tracker: the rule (e.g. `no-effect-event-in-deps`) only mutates the
// top frame for VariableDeclarators directly inside a component, and
// the stack only grows on FunctionDeclaration / VariableDeclarator
// component entries, so a closed-over name from an outer component
// can't leak in via a nested helper.
export const createComponentBindingStackTracker = (
callbacks?: ComponentBindingStackTrackerCallbacks,
): ComponentBindingStackTracker => {
const componentBindingStack: Array<Set<string>> = [];

const isInsideComponent = (): boolean => componentBindingStack.length > 0;

const isBoundName = (name: string): boolean => {
for (let frameIndex = componentBindingStack.length - 1; frameIndex >= 0; frameIndex--) {
if (componentBindingStack[frameIndex].has(name)) return true;
}
return false;
};

const addBindingToCurrentFrame = (name: string): void => {
if (componentBindingStack.length === 0) return;
componentBindingStack[componentBindingStack.length - 1].add(name);
};

const visitors: RuleVisitors = {
FunctionDeclaration(node: EsTreeNode) {
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
componentBindingStack.push(new Set());
},
"FunctionDeclaration:exit"(node: EsTreeNode) {
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
componentBindingStack.pop();
},
VariableDeclarator(node: EsTreeNode) {
if (isComponentAssignment(node)) {
componentBindingStack.push(new Set());
return;
}
callbacks?.onVariableDeclarator?.(node);
},
"VariableDeclarator:exit"(node: EsTreeNode) {
if (isComponentAssignment(node)) componentBindingStack.pop();
},
};

return { isInsideComponent, isBoundName, addBindingToCurrentFrame, visitors };
};
Loading
Loading