Skip to content

feat: library-aware React 19 rules + test-file scoping + build-entry dead-code + js-set-map-lookups string fix#186

Open
aidenybai wants to merge 10 commits into
mainfrom
cursor/library-aware-deprecation-rules-ec3b
Open

feat: library-aware React 19 rules + test-file scoping + build-entry dead-code + js-set-map-lookups string fix#186
aidenybai wants to merge 10 commits into
mainfrom
cursor/library-aware-deprecation-rules-ec3b

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented May 9, 2026

Motivation

User feedback from a library author who scanned @pierre/diffs (rolled up across multiple rounds):

Obvi what i talked about in the tweet with like this being a library and trying to increase compatibility, we use forwardRef for that reason. But in a react 19 app obvi you shouldn't be using that, so i get the decision.

we have 2 files that are exported from our build, but not imported anywhere, that get listed as dead code

I kinda wonder if test or tests directory and/or files should be ignored?

also getting penalized for using indexOf on a string … also getting dinged for using includes on a string (Element.textContent)

This PR addresses all four — three new project-driven defaults plus a targeted false-positive fix on js-set-map-lookups.

Four fixes

1. Library-aware React 19 deprecation rules

When a package declares react as a peerDependency with a range that admits any React major below 19 (e.g. ^17 || ^18 || ^19, >=17, ^16.8 || ^17 || ^18 || ^19, plain *, or a pnpm catalog: reference resolving to one of the above), three rules now skip:

  • react-doctor/no-react19-deprecated-apis (forwardRef, useContext)
  • react-doctor/no-default-props
  • react-doctor/no-react-dom-deprecated-apis

The library MUST keep these APIs to honor its peer contract. prefer-use-effect-event and other prefer-newer-api rules keep firing — library context is about not breaking peer compat, not about hiding generally useful API recommendations.

2. Test-file rule scoping

Tests intentionally exercise patterns that would be wrong in production: array-index keys in fixture rows, oversize fixture components, forwardRef/defaultProps/flushSync assertions, design-system spot checks, micro-perf patterns in fixture loops, etc. Files matching .test.* / .spec.* / .stories.* or anything under __tests__ / tests / test / __mocks__ / cypress / e2e / playwright now skip a curated set of "noise-in-fixtures" rules. Correctness rules (rules-of-hooks, effect-needs-cleanup, the react-hooks-js/* compiler rules, alt-text, role-has-required-aria-props, …) keep firing — a buggy test is still a bug.

Opt out via suppressNoiseRulesInTestFiles: false.

3. Build-entry dead-code suppression

Knip's files rule flags source files that no other source file imports — but library entry points (CLI scripts, web/service workers, additional bundle entries) are imported by the BUILD process, not by other source files. We now suppress knip/files when the source has a matching artifact under any of the standard build dirs (dist / build / lib / out / esm / cjs) with a .js / .mjs / .cjs extension.

Conservative in the right direction: false negatives (real dead code with a coincidental dist artifact) are vanishingly rare; false positives (real entry flagged as dead) were the actual user-facing pain. Requires the project to have been built — pre-build, every entry is still flagged.

Opt out via suppressDeadCodeForBuildEntries: false.

4. js-set-map-lookups skips string receivers

The rule's premise — "convert to a Set for O(1) lookups" — only applies to arrays. Strings have no Set equivalent for substring search, so flagging string.indexOf() / string.includes() is wrong advice. Two reported false positives:

  • contents.indexOf('\n', cursor) where contents: string
  • style?.textContent?.includes(expected) (DOM string property)

The rule now skips when the receiver is obviously a string. High-confidence detection signals (no type info available at the AST layer):

  • String / template literal receiver
  • String(...) cast
  • String-returning method call (.toString, .toLowerCase, .toUpperCase, .trim*, .pad*, .replace*, .substring, .substr, .normalize, .repeat, .charAt, .toFixed, …)
  • Member access ending in a known DOM/built-in string property (.textContent, .innerHTML, .outerHTML, .innerText, .nodeValue, .tagName, .className, URL parts, error properties, …)
  • Identifier with a name that overwhelmingly binds to a string (text, contents, html, json, url, path, line, needle, …) — ambiguous names like data, value, item deliberately omitted
  • ChainExpression wrapper transparently unwrapped so optional chaining (a?.textContent?.includes(x)) still detects the string property at the tail

Plus all JS micro-perf and async-perf rules (js-set-map-lookups, js-flatmap-filter, js-combine-iterations, async-await-in-loop, …) are now in the test-file disabled set — fixture loops over allow-lists shouldn't be held to production-perf standards.

The array-on-array case (the actual perf concern) still fires — verified by regression tests covering array.includes() and array.indexOf() on plain array variables.

Implementation notes

  • peerRangeSupportsLegacyReact() splits on semver's boolean separators (||, ,, whitespace), and a comparator votes "supports legacy" if it's a pure wildcard (*/x.x.x) or its first integer is in [1, 19). One vote flips the range. Reading only the first integer per comparator avoids false positives on minor/patch zeros (^19.0.0) and canary hex digits (0.0.0-canary-abc).
  • resolveReactPeerRange() reads the local package's peerDependencies.react, defensively guards against malformed entries (null / numbers), and resolves pnpm catalog: references through the catalog system. Named catalog references ("catalog:react18") forward the catalog name to resolveCatalogVersion so multi-grouped catalogs resolve to the correct group. Falls back to the monorepo root when the leaf package's directory has no pnpm-workspace.yaml (the common shape).
  • filterAutomaticSuppressions runs as a project-driven (no user-config required) filter pass in mergeAndFilterDiagnostics, ahead of the existing user-config / inline-suppression layers. Per-file results cached so an N-diagnostic file pays the test/build-entry checks once.
  • isLikelyStringReceiver lives next to the jsSetMapLookups rule — local to the one rule that needs it, no premature abstraction.
  • New boolean config fields wired into validateConfigTypes so JSON-typing mistakes are coerced + warned.

Tests

  • parse-react-peer-range.test.ts (7 cases): basic legacy-supporting ranges, 19+only ranges, wildcards, npm dist-tags, canary tags, double-counting 0 digits.
  • discoverProject cases: existing fixture, library with ^19.0.0 peer (no relaxation), app with no peer dep, wide ^16.8 || ^17 || ^18 || ^19 peer, * wildcard peer, pnpm catalog peer (resolves through monorepo root), named catalog peer (catalog:react18 correctly picks the react18 group over a sibling react19 group), malformed peer (react: null).
  • react-19-migration-rules.test.ts: each of the three deprecation rules in library mode + a "still flags prefer-use-effect-event in library mode" check.
  • is-test-file.test.ts (7 cases): .test.* / .spec.* suffixes, Storybook fixtures, test directories at any depth, Windows path normalization, false positives like contestants/, protest/, testing-utils.ts.
  • is-likely-build-entry.test.ts (9 cases): src/cli.ts ↔ dist/cli.js, the user-reported worker entries, every (build dir, ext) combo, top-level vs nested files, missing artifacts, Windows paths.
  • filter-automatic-suppressions.test.ts (11 cases): test-file scoping including opt-out, build-entry suppression including opt-out, fast-path when both disabled, JS micro-perf rules dropped in test files (regression).
  • js-set-map-lookups-string-fp.test.ts (9 cases): contents.indexOf('\n'), Element.textContent?.includes(), string literal / template literal / String(...) / .toLowerCase() chain receivers, optional-chained DOM property, plus two "still flags arrays" sanity cases for the array-on-array regression risk.

All 780 tests pass; lint, typecheck, format are clean.

Review feedback

  • Cursor Bugbot (commit ec42a02): caught a low-severity copy-paste duplicate — "tagName" listed twice in STRING_TYPED_PROPERTY_NAMES. Fixed in 49f0075.
  • Cursor Bugbot (commit e49eee0, post first merge): caught named catalog reference being dropped — resolveReactPeerRange was calling resolveCatalogVersion without forwarding the catalog NAME, so "catalog:react18" resolved against an arbitrary group rather than the requested react18 one. Fixed in 1336a0a plus a regression test that verifies the react18 group is picked over a sibling react19 group.
  • Two origin/main rebases handled cleanly: PR feat(plugin): integrate eslint-plugin-react-you-might-not-need-an-effect #201 (eslint-plugin-react-you-might-not-need-an-effect) and PR feat(react-doctor): detect Tailwind version and gate size-N rule on it #202 (Tailwind version detection), both of which extended the same OxlintConfigOptions interface and call sites I added isLibraryTargetingLegacyReact to.
Open in Web Open in Cursor 

…h legacy peer deps

When a package being scanned declares `react` as a `peerDependency` with a
range that admits any React major below 19 (e.g. `^17.0.0 || ^18.0.0 || ^19.0.0`),
the React-19-deprecation rules become noise — the library MUST keep using
`forwardRef`, `defaultProps`, and the legacy `react-dom` root API to honor
its peer contract.

Detected at `discoverProject` time and surfaced on `ProjectInfo` via two
new fields:
- `reactPeerRange`: the raw `peerDependencies.react` range, or null
- `isLibraryTargetingLegacyReact`: true when the peer range admits a major < 19

When that flag is set, `createOxlintConfig` skips the three
`deprecation-warning` rules (`no-react19-deprecated-apis`,
`no-default-props`, `no-react-dom-deprecated-apis`). The
`prefer-newer-api` gates (`prefer-use-effect-event`) keep firing —
library mode is about not breaking peer compat, not about hiding
generally useful API recommendations.

The scan output also adds a detection step so it's clear when this
mode kicks in.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 10, 2026 3:04pm

…orkspace prop

Review-pass fixes for the library-mode detection added in the prior commit:

1. Wildcard peer ranges (`peerDependencies.react: "*"`, `"x"`,
   `"x.x.x"`) now correctly trigger library mode. The first-integer
   parser returned false on these because no integers existed; added an
   explicit wildcard branch.

2. pnpm catalog references in the peer dep (`"catalog:"` /
   `"catalog:react18"`) are now resolved through the surrounding
   monorepo's catalog system. The first attempt only checked the
   leaf package's directory, but pnpm-workspace.yaml lives at the
   monorepo root — added the same root-fallback the version
   resolution already uses.

3. Removed misleading dead code: the prior commit propagated
   `reactPeerRange` through `findReactInWorkspaces` and
   `findDependencyInfoFromMonorepoRoot`, but `discoverProject`
   correctly only reads the LOCAL peer range (a sibling's peer dep
   tells us nothing about THIS package's compat surface). The
   propagation was never read; moved the field off `DependencyInfo`
   and into a focused `resolveReactPeerRange` helper.

4. Defensive guard against malformed package.json (`react: null`,
   `react: 19`-as-number) — the runtime shape isn't bound by the
   TS type, so a non-string entry is treated as "no peer dep".

5. README mentions library mode in the rule-toggling sentence so
   library authors know it auto-engages.

New tests cover the wildcard branch (`*`, `x`, `x.x.x`), the pnpm
catalog peer-dep path, and malformed peer entries.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai
Copy link
Copy Markdown
Member Author

react review

1 similar comment
@aidenybai
Copy link
Copy Markdown
Member Author

react review

cursoragent and others added 2 commits May 9, 2026 12:41
…ngle predicate

Folds the wildcard branch into the per-comparator predicate. A
comparator votes "supports legacy" if it's a pure wildcard (`*` /
`x.x.x`) or its first integer is in [1, 19); one vote flips the
range. Removes the separate `comparators.every(isWildcard)`
short-circuit, the intermediate `comparators.length === 0` guard
(`some()` on an empty array is `false` already), and the
`Number.isFinite` check (already covered by the regex matching only
ASCII digits).

Same observable behavior; ~25 fewer lines and one obvious flow
instead of two parallel ones.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…code FPs

Two new project-driven defaults addressing real-world noise reported
on @pierre/diffs:

1. Test-file rule scoping. Tests intentionally exercise patterns that
   would be wrong in production (array-index keys in fixture rows,
   oversize fixture components, `forwardRef` / `defaultProps` /
   `flushSync` assertions, design-system spot checks). Detect test
   files by path shape — `.test.*` / `.spec.*` / `.stories.*` and
   anything under `__tests__` / `tests` / `test` / `__mocks__` /
   `cypress` / `e2e` / `playwright` — and suppress a curated set of
   "noise-in-fixtures" rules. Correctness rules (`rules-of-hooks`,
   `effect-needs-cleanup`, the `react-hooks-js/*` compiler rules,
   `alt-text`, …) keep firing because a buggy test is still a bug.
   Opt out via `suppressNoiseRulesInTestFiles: false`.

2. Build-entry dead-code suppression. Knip's `files` rule flags
   source files that no other source file imports — but library
   entry points (CLI scripts, web/service workers, additional bundle
   entries) are imported by the BUILD process, not by source.
   Suppress `knip/files` when the source has a matching artifact
   under any of the standard build dirs (`dist` / `build` / `lib` /
   `out` / `esm` / `cjs`) with a `.js` / `.mjs` / `.cjs` extension.
   Conservative in the right direction: false negatives (real dead
   code with a coincidental dist artifact) are vanishingly rare;
   false positives (real entry flagged as dead) were the user-facing
   pain. Opt out via `suppressDeadCodeForBuildEntries: false`.

Both run as a project-driven (no user-config required) filter pass
in `mergeAndFilterDiagnostics`, ahead of the existing user-config /
inline-suppression layers. The new boolean fields are added to
`validateConfigTypes` so JSON-typing mistakes are coerced + warned
the same way `adoptExistingLintConfig` etc. are. README updated
with the new behavior and opt-out flags.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 9, 2026

Note

No issues found

@cursor cursor Bot changed the title feat: skip React 19 deprecation rules for libraries with legacy peer deps feat: library-aware React 19 rules + test-file scoping + build-entry dead-code suppression May 9, 2026
@aidenybai aidenybai marked this pull request as ready for review May 10, 2026 01:23
…rewrite for substring search)

Reported false positives on @pierre/diffs:
- `contents.indexOf('\n', cursor)` where `contents: string`
- `style?.textContent?.includes(expected)` (DOM string property)
- `array.includes()` in fixture loops inside test files

The rule's premise — "convert to a Set for O(1) lookups" — only
applies to arrays. Strings have no Set equivalent for substring search,
so flagging `string.indexOf()` / `string.includes()` is wrong advice.

Two fixes:

1. Skip when the receiver is obviously a string. Detection signals
   (high-confidence, no type info available at the AST layer):
   - String / template literal receiver
   - `String(...)` cast
   - String-returning method call: `.toString`, `.toLowerCase`,
     `.toUpperCase`, `.trim*`, `.pad*`, `.replace*`, `.substring`,
     `.substr`, `.normalize`, `.repeat`, `.charAt`, `.toFixed` etc.
   - Member access ending in a known DOM/built-in string property:
     `.textContent`, `.innerHTML`, `.outerHTML`, `.innerText`,
     `.nodeValue`, `.tagName`, `.className`, URL parts, error
     properties, etc.
   - Identifier with a name that overwhelmingly binds to a string:
     `text`, `contents`, `html`, `json`, `url`, `path`, `line`,
     `needle`, etc. (ambiguous names like `data`, `value`, `item`
     deliberately omitted — those bind to arrays nearly as often)
   - ChainExpression wrapper transparently unwrapped so optional
     chaining (`a?.textContent?.includes(x)`) still detects the
     string property at the tail.

2. Add the JS micro-perf and async-perf rules to the test-file
   disabled set (`js-set-map-lookups`, `js-flatmap-filter`,
   `js-combine-iterations`, `js-tosorted-immutable`,
   `js-hoist-regexp`, `js-hoist-intl`, `js-cache-property-access`,
   `js-length-check-first`, `js-min-max-loop`, `js-batch-dom-css`,
   `js-index-maps`, `js-cache-storage`, `js-early-exit`,
   `async-defer-await`, `async-await-in-loop`, `async-parallel`).
   Test code isn't on the hot path; fixture loops over allow-lists
   shouldn't be held to production-perf standards.

The array-on-array case (the actual perf concern) still fires —
verified by two regression tests covering `array.includes()` and
`array.indexOf()` on plain array variables.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot changed the title feat: library-aware React 19 rules + test-file scoping + build-entry dead-code suppression feat: library-aware React 19 rules + test-file scoping + build-entry dead-code + js-set-map-lookups string fix May 10, 2026
Comment thread packages/react-doctor/src/plugin/rules/js-performance.ts Outdated
@aidenybai
Copy link
Copy Markdown
Member Author

react review

1 similar comment
@aidenybai
Copy link
Copy Markdown
Member Author

react review

…ugbot review)

Bugbot caught a low-severity copy-paste duplicate: `"tagName"` was
listed both in the "Node text accessors" group and the "Element
identity / metadata" group. Harmless at runtime (the `Set` dedupes)
but a real review nit. Moved to the metadata group where it
semantically fits — tagName describes element identity, not text
content.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai
Copy link
Copy Markdown
Member Author

react review

2 similar comments
@aidenybai
Copy link
Copy Markdown
Member Author

react review

@aidenybai
Copy link
Copy Markdown
Member Author

react review

…plugin + new fixtures)

Conflict 1: README.md — main added an "Optional companion plugins"
section right where I added the `suppressNoiseRulesInTestFiles` /
`suppressDeadCodeForBuildEntries` config docs. Kept both sections
side-by-side.

Conflict 2: oxlint-config.ts — main spread `youMightNotNeedEffectRules`
between `reactCompilerRules` and `filterRulesByReactMajor(...)`,
right where I extended the `filterRulesByReactMajor` signature to
accept `isLibraryTargetingLegacyReact`. Kept both: the new spread is
preserved, AND the third arg lands on the filter call.

All 758 tests pass (737 mine + ~21 new from main); lint, typecheck,
format clean.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai
Copy link
Copy Markdown
Member Author

react review

1 similar comment
@aidenybai
Copy link
Copy Markdown
Member Author

react review

main #202 added `tailwindVersion` plumbing through the same
ProjectInfo → scan → runOxlint → createOxlintConfig pipeline I added
`isLibraryTargetingLegacyReact` to. Resolved by including BOTH params
at every layer:

- `OxlintConfigOptions.tailwindVersion` lives next to
  `isLibraryTargetingLegacyReact` in the interface.
- `createOxlintConfig` destructures both with their defaults.
- The rule pipeline composes the filters: Tailwind gating wraps the
  React-major+library gating output.
- `runOxlint` forwards both, including in the no-extends fallback
  call. `scan` and `diagnose` pass `projectInfo.tailwindVersion`
  alongside `projectInfo.isLibraryTargetingLegacyReact`.
- `discoverProject` destructures `tailwindVersion` from
  `extractDependencyInfo` while keeping the `reactPeerRange`
  resolution unchanged.
- `collectRuleHits` test helper passes both options.

779 tests pass (758 mine + ~21 new from main); lint, typecheck,
format clean.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e49eee0. Configure here.

Comment thread packages/react-doctor/src/utils/discover-project.ts Outdated
…peer ranges (Bugbot review)

Bugbot caught: `resolveReactPeerRange` was calling
`resolveCatalogVersion` without forwarding the catalog name extracted
from the peer-range string. When a project's
`peerDependencies.react` is `"catalog:react18"` and the workspace
has multiple grouped catalogs (e.g. `react18` and `react19`), the
resolver would fall through to the `Object.values()` group scan and
silently pick whichever catalog iterated first — producing wrong
library-mode verdicts.

Fix mirrors the `leafReactCatalogReference` plumbing already used by
the main `reactVersion` resolver: extract the catalog name via
`extractCatalogName` and pass it to `resolveCatalogVersion` as the
explicit `catalogReference` argument. Both the local and monorepo-
root resolution paths now route correctly.

Regression test verifies the `react18` group is picked over
`react19` when the peer is `"catalog:react18"`.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Per-feature docs in the intro (library mode, test-file scoping,
build-entry suppression) were noisy — they don't fit the high-level
"score + issue list" framing. The dedicated config entries in the
Configuration section (`suppressNoiseRulesInTestFiles`,
`suppressDeadCodeForBuildEntries`) cover the same ground for users
who actually need to toggle them.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants