Skip to content
Closed
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
38 changes: 38 additions & 0 deletions .changeset/port-effect-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@react-doctor/core": patch
"oxlint-plugin-react-doctor": patch
"react-doctor": patch
---

Natively port the 8 rules from `eslint-plugin-react-you-might-not-need-an-effect`
(NickvanDyke, MIT) into `oxlint-plugin-react-doctor`. They now ship as
`react-doctor/*` rules and no longer require the optional peer
dependency. The optional peer-dep surface (`effect/*` rules,
`resolveYouMightNotNeedEffectPlugin`,
`YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE`) is removed from
`@react-doctor/core`.

The ports use a real `eslint-scope` ScopeManager (cached per Program
via `WeakMap`) — same `references` / `resolved.defs[].node.init` /
`isEventualCallTo` chasing the upstream plugin uses. Diagnostic
messages match upstream verbatim with template variables substituted
in JS.

| Rule (now `react-doctor/<id>`) | What it catches |
| ----------------------------------- | ------------------------------------------------------------------------ |
| `no-derived-state` | Storing derived state via a useEffect instead of computing during render |
| `no-chain-state-updates` | Chaining state updates across effects |
| `no-event-handler` | Using state + a guarded effect as an event handler |
| `no-adjust-state-on-prop-change` | Adjusting state in an effect when a prop changes |
| `no-reset-all-state-on-prop-change` | Resetting all state in an effect (use a `key` prop) |
| `no-pass-live-state-to-parent` | Pushing live state to a parent via a callback in an effect |
| `no-pass-data-to-parent` | Passing fetched data to a parent via a callback in an effect |
| `no-initialize-state` | Initializing state inside a mount-only effect |

Parity coverage: 147 of 148 upstream test cases pass (the 1 remaining
case is upstream's own `todo: true`, a renamed-`useState` import).

These coexist with React Doctor's existing thematically-related rules
(`no-derived-state-effect`, `no-effect-chain`, `no-event-trigger-state`,
`no-prop-callback-in-effect`) — different IDs, different shapes,
different messages.
9 changes: 2 additions & 7 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,14 @@
},
"devDependencies": {
"@types/node": "^25.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1"
"eslint-plugin-react-hooks": "^7.1.1"
},
"peerDependencies": {
"eslint-plugin-react-hooks": "^6 || ^7",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10"
"eslint-plugin-react-hooks": "^6 || ^7"
},
"peerDependenciesMeta": {
"eslint-plugin-react-hooks": {
"optional": true
},
"eslint-plugin-react-you-might-not-need-an-effect": {
"optional": true
}
},
"engines": {
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ export const FETCH_TIMEOUT_MS = 10_000;
export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000;

// HACK: bound per-batch work so that JS-evaluated plugins with bad
// scaling (notably `eslint-plugin-react-you-might-not-need-an-effect`
// — verified to hit the 5-min spawn timeout on supabase/studio's ~3500
// source files at batch=500, productive at batch=100) stay tractable
// AND so that oxlint doesn't SIGABRT from memory pressure on very
// large file sets. Smaller batches add ~50ms spawn overhead per
// extra batch — negligible vs the hard-cap perf cliffs they prevent.
// scaling (originally the upstream `effect` plugin — verified to hit
// the 5-min spawn timeout on supabase/studio's ~3500 source files at
// batch=500, productive at batch=100; same characteristics apply to
// the ported `react-doctor/no-derived-state` family because both rely
// on whole-component scope walking) stay tractable AND so that oxlint
// doesn't SIGABRT from memory pressure on very large file sets.
// Smaller batches add ~50ms spawn overhead per extra batch — negligible
// vs the hard-cap perf cliffs they prevent.
export const OXLINT_MAX_FILES_PER_BATCH = 100;

export const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
Expand Down
20 changes: 11 additions & 9 deletions packages/core/src/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,13 +497,14 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
// HACK: when `includePaths` is undefined we used to pass `["."]` and
// let oxlint walk the tree itself. That defeated batching entirely
// — verified on supabase/studio (3567 source files) that JS-plugin
// rules (notably `eslint-plugin-react-you-might-not-need-an-effect`)
// hit the 5-min `OXLINT_SPAWN_TIMEOUT_MS` in a single batch, leaving
// `skippedChecks: ["lint"]` and zero diagnostics for the entire
// project. Materializing the file list ahead of time and feeding
// it through `batchIncludePaths` keeps each spawn under the timeout
// (~7-8s per 100-file batch on studio) and recovers the diagnostics
// we were silently dropping.
// rules (originally the upstream `effect` plugin; now the natively
// ported `react-doctor/no-derived-state` family with comparable
// scope-walking cost) hit the 5-min `OXLINT_SPAWN_TIMEOUT_MS` in a
// single batch, leaving `skippedChecks: ["lint"]` and zero
// diagnostics for the entire project. Materializing the file list
// ahead of time and feeding it through `batchIncludePaths` keeps
// each spawn under the timeout (~7-8s per 100-file batch on studio)
// and recovers the diagnostics we were silently dropping.
const fileBatches = batchIncludePaths(
baseArgs,
includePaths !== undefined ? includePaths : listSourceFiles(rootDirectory),
Expand Down Expand Up @@ -532,8 +533,9 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
// binary-split below: large batches that time out / OOM split in
// half and retry; the only files that reach this set are the
// genuinely-pathological ones (e.g. one file × one quadratic
// JS-plugin rule, like supabase/studio's `apps/studio/pages/...`
// bucket against `eslint-plugin-react-you-might-not-need-an-effect`).
// JS-plugin rule, originally hit on supabase/studio's
// `apps/studio/pages/...` bucket against the upstream `effect`
// plugin and now applicable to the native port).
const droppedFiles: string[] = [];

const spawnLintBatch = async (batch: string[]): Promise<Diagnostic[]> => {
Expand Down
19 changes: 1 addition & 18 deletions packages/core/src/runners/oxlint/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@ import reactDoctorPlugin, {
BUILTIN_A11Y_RULES,
BUILTIN_REACT_RULES,
REACT_COMPILER_RULES,
YOU_MIGHT_NOT_NEED_EFFECT_RULES,
} from "oxlint-plugin-react-doctor";
import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor";
import type { ProjectInfo } from "@react-doctor/types";
import { buildCapabilities, shouldEnableRule } from "./capabilities.js";
import {
filterRulesToAvailable,
resolveReactHooksJsPlugin,
resolveYouMightNotNeedEffectPlugin,
YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
} from "./plugin-resolution.js";
import { filterRulesToAvailable, resolveReactHooksJsPlugin } from "./plugin-resolution.js";
import type { JsPluginEntry } from "./plugin-resolution.js";

export interface OxlintConfigOptions {
Expand Down Expand Up @@ -47,18 +41,8 @@ export const createOxlintConfig = ({
)
: {};

const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin
? filterRulesToAvailable(
YOU_MIGHT_NOT_NEED_EFFECT_RULES,
YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
youMightNotNeedEffectPlugin.availableRuleNames,
)
: {};

const jsPlugins: JsPluginEntry[] = [];
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);

const capabilities = buildCapabilities(project);

Expand Down Expand Up @@ -99,7 +83,6 @@ export const createOxlintConfig = ({
...(customRulesOnly ? {} : BUILTIN_REACT_RULES),
...(customRulesOnly ? {} : BUILTIN_A11Y_RULES),
...reactCompilerRules,
...youMightNotNeedEffectRules,
...enabledReactDoctorRules,
},
};
Expand Down
28 changes: 0 additions & 28 deletions packages/core/src/runners/oxlint/plugin-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ interface ResolvedReactHooksJsPlugin {
availableRuleNames: ReadonlySet<string>;
}

interface ResolvedYouMightNotNeedEffectPlugin {
entry: JsPluginEntry;
availableRuleNames: ReadonlySet<string>;
}

interface MaybePluginModule {
rules?: Record<string, unknown>;
default?: { rules?: Record<string, unknown> };
Expand Down Expand Up @@ -62,29 +57,6 @@ export const resolveReactHooksJsPlugin = (
};
};

// HACK: oxlint-namespaces this third-party ESLint plugin under
// `effect` so the long upstream package name doesn't clutter rule
// keys. Issue #187 - adds the plugin's complementary rule surface
// alongside react-doctor's native `state-and-effects` rules. The
// plugin is opt-in: skipped when not installed (peer is optional).
export const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";

export const resolveYouMightNotNeedEffectPlugin = (
customRulesOnly: boolean,
): ResolvedYouMightNotNeedEffectPlugin | null => {
if (customRulesOnly) return null;
let pluginSpecifier: string;
try {
pluginSpecifier = esmRequire.resolve("eslint-plugin-react-you-might-not-need-an-effect");
} catch {
return null;
}
return {
entry: { name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, specifier: pluginSpecifier },
availableRuleNames: readPluginRuleNames(pluginSpecifier),
};
};

export const filterRulesToAvailable = (
rules: Record<string, OxlintRuleSeverity>,
pluginNamespace: string,
Expand Down
19 changes: 18 additions & 1 deletion packages/oxlint-plugin-react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

[oxlint](https://oxc.rs/docs/guide/usage/linter) plugin for [React Doctor](https://react.doctor). Diagnoses React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues.

This package owns the rule implementations (178 rules across architecture, performance, correctness, security, accessibility, bundle-size, and framework-specific buckets). [`eslint-plugin-react-doctor`](https://npmjs.com/package/eslint-plugin-react-doctor) wraps these same rules for ESLint, and the full diagnostic CLI lives in [`react-doctor`](https://npmjs.com/package/react-doctor).
This package owns the rule implementations (187 rules across architecture, performance, correctness, security, accessibility, bundle-size, and framework-specific buckets). [`eslint-plugin-react-doctor`](https://npmjs.com/package/eslint-plugin-react-doctor) wraps these same rules for ESLint, and the full diagnostic CLI lives in [`react-doctor`](https://npmjs.com/package/react-doctor).

## Install

Expand Down Expand Up @@ -56,6 +56,23 @@ Each rule can be set to `"error"`, `"warn"`, or `"off"`:
}
```

## "You Might Not Need an Effect" rule family

Eight rules ported 1:1 from [`eslint-plugin-react-you-might-not-need-an-effect`](https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect) (MIT, NickvanDyke) ship natively in this package — same rule IDs, same diagnostic messages, same semantics (147 of 148 upstream test cases pass; the remaining one is upstream's own `todo: true`). Attribution and known divergences live in [`SOURCE.md`](https://github.com/millionco/react-doctor/blob/main/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md).

| Rule | What it catches |
| ------------------------------------------------ | ----------------------------------------------------------------------------- |
| `react-doctor/no-derived-state` | Storing derived state via `useEffect` instead of computing during render |
| `react-doctor/no-chain-state-updates` | Chaining state updates across effects |
| `react-doctor/no-event-handler` | Using state + a guarded effect as an event handler |
| `react-doctor/no-adjust-state-on-prop-change` | Adjusting state in an effect when a prop changes |
| `react-doctor/no-reset-all-state-on-prop-change` | Resetting all state in an effect (use a `key` prop instead) |
| `react-doctor/no-pass-live-state-to-parent` | Pushing live state to a parent via a callback in an effect |
| `react-doctor/no-pass-data-to-parent` | Passing fetched data to a parent via a callback in an effect |
| `react-doctor/no-initialize-state` | Initializing state inside a mount-only effect (pass it to `useState` instead) |

If you previously enabled them as `effect/*` via the optional peer dep, drop the peer dep — they're enabled by default through React Doctor's CLI config now.

## Want the CLI too?

This package only ships the oxlint plugin. To run React Doctor's full scan (with scoring, JSON reports, agent integration, etc.), use the main CLI:
Expand Down
6 changes: 5 additions & 1 deletion packages/oxlint-plugin-react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@
"test": "pnpm gen && vp test run"
},
"dependencies": {
"@typescript-eslint/types": "^8.59.3"
"@types/eslint-scope": "^9.1.0",
"@types/eslint-visitor-keys": "^3.3.2",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deprecated @types stubs listed as production dependencies

Low Severity

@types/eslint-scope and @types/eslint-visitor-keys are placed under dependencies instead of devDependencies. Both are deprecated stub packages (the main packages ship their own types), meaning they add unnecessary weight to every consumer's node_modules at install time without providing any runtime value.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 288732c. Configure here.

"@typescript-eslint/types": "^8.59.3",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1"
},
"devDependencies": {
"@react-doctor/types": "workspace:*",
Expand Down
Loading
Loading