Files
millionco__react-doctor/docs/rule-candidates-backlog.md
Aiden Bai 451beeb284 feat(rules): add 3 mined platform/correctness rules (batch 2) (#828)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
2026-06-16 00:06:34 -04:00

14 KiB
Raw Permalink Blame History

Rule Candidates Backlog

Synthesized from a 12-cluster mining pass over ~/Developer/brain (react-dev, Kent C. Dodds, React perf, web perf, Next/Vercel RSC, security, React Native, modern-web, Solid, deep a11y, ui.sh components, design/motion). ~200 raw candidates → deduped, cross-corroborated, and flagged against the current ~391-rule inventory.

Legend: corro = number of independent clusters that proposed it (higher = safer signal). Precision: syntax | scope (needs binding/import resolution) | path (needs control flow). Each is statically detectable with the noted false-positive risk. Already-shipped this branch is excluded; overlaps with existing rules are flagged.


Tier S — implement next (cross-corroborated, low-FP, clearly new)

  1. no-call-component-as-function — calling a component as a plain function (Foo(props)) instead of <Foo />; breaks hooks/identity/memoization. scope · low FP. (corro 3: react-dev, kent, react-perf)
    • Sidebar(props) → <Sidebar {...props} />
  2. no-async-effect-callback — useEffect(async () => …) (effect cleanup gets a Promise, races on unmount). scope · low. (corro 2: kent, solid as framework-agnostic)
    • useEffect(async () => { await load() }, []) → define an inner async fn and call it.
  3. img-missing-dimensions — dropped, not statically sound. width/height attrs are only one way to reserve space; CSS aspect-ratio/container sizing work too and live in stylesheets a linter can't see. This is a runtime check (Lighthouse unsized-images), not a static one. (corro 3, but defeated by CSS invisibility)
  4. no-json-parse-stringify-clone — JSON.parse(JSON.stringify(x)) deep clone (slow, drops Dates/Map/undefined). syntax · low. (corro 2: react-perf, web-perf) → structuredClone(x).
  5. no-create-ref-in-function-component — createRef() in a function component (new ref every render) → useRef. scope · low. (corro 2: react-dev, kent)
  6. no-set-state-in-usememo — calling a setter inside useMemo/useCallback factory (side effect in render). path · low. (corro 2: react-dev, kent)
  7. no-props-mutation — assigning to props.x / mutating a prop object. path · low-med. (corro 2: react-dev, kent)
  8. dialog-has-accessible-name — <dialog> / role="dialog" with no aria-label/aria-labelledby. syntax · low. (corro 2: modern-web, a11y-deep)
  9. no-aria-label-on-generic-element — aria-label on a non-interactive, role-less <div>/<span> (ignored by AT). scope · med (skip role/tabindex). (corro 1: a11y-deep) — complements shipped no-uninformative-aria-label.
  10. auth-token-in-web-storage — writing a token/JWT/secret-named value to localStorage/sessionStorage (XSS-exfiltratable). scope · med. (corro 1: security) — high severity.

Tier A — strong, new, low/med FP

Correctness / React

  • no-object-or-array-literal-as-key — key={{…}}/key={[…]}/key={obj} (new identity each render → remount). syntax · low. (kent)
  • no-state-initialized-from-prop-without-key — useState(props.x) as the only sync (stale on prop change). scope · med. (kent; overlaps no-derived-state family — verify before building)
  • no-ref-current-in-render — reading/writing ref.current during render (not in effect/handler). path · med. (react-dev)
  • no-module-level-mutable-in-component — reassigning a module-scope let from render. scope · med. (react-dev)
  • no-jsx-element-in-usememo — useMemo(() => <X/>, …) (memoizing elements is usually wrong). syntax · med. (kent)

Performance

  • no-subscription-in-render — .subscribe() / addEventListener called in render body. path · low. (react-perf)
  • no-storage-access-in-render — localStorage.getItem/document.cookie read in render (sync I/O + hydration mismatch). path · med. (react-perf; corroborates web-perf)
  • no-window-size-in-render — window.innerWidth/matchMedia().matches in render (hydration mismatch). path · med. (react-perf, react-native web analog)
  • no-accumulator-spread-in-reduce — arr.reduce((acc,x)=>({...acc,…}),{}) O(n²). path · med. (web-perf)
  • no-new-instance-as-prop — new Date()/new RegExp()/new Intl.* literal in JSX prop. syntax · med. (react-perf; sibling of jsx-no-new-object-as-prop)

Web platform / perf

  • no-document-write — document.write(…). syntax · low. (web-perf)
  • no-sync-xhr — new XMLHttpRequest(); …open(…, false). scope · low. (web-perf)
  • no-blocking-sync-dialog — alert()/confirm()/prompt(). syntax · med (dev usage). (web-perf)
  • iframe-missing-lazy-loading — <iframe> without loading="lazy". syntax · low-med. (web-perf)
  • no-img-lazy-with-high-fetchpriority — <img loading="lazy" fetchPriority="high"> (contradiction; LCP). syntax · low. (modern-web)
  • img-srcset-requires-sizes — <img srcset> (or <source>) without sizes. syntax · low. (modern-web)
  • link-preload-missing-as / link-font-preload-missing-crossorigin — <link rel="preload"> correctness. syntax · low. (web-perf)
  • no-wildcard-namespace-import — import * as X from "<known-large pkg>" (tree-shaking-hostile; needs a pkg allowlist). scope · med. (web-perf)

Forms (modern-web + a11y, strongly corroborated)

  • no-type-number-for-formatted-input — <input type="number"> for phone/OTP/card/zip (use inputmode+type=text). scope · med. (modern-web, a11y-deep)
  • prefer-input-type-email-tel — email/tel field as type="text" with no inputMode. scope · med. (modern-web)
  • password-input-requires-autocomplete — <input type="password"> without autoComplete (current-password/new-password). syntax · med. (modern-web)
  • no-autocomplete-off-on-identity-field — autoComplete="off" on name/email/address fields. syntax · med. (modern-web, a11y-deep)

Accessibility (beyond jsx-a11y)

  • role-img-requires-name — role="img" without aria-label. syntax · low. (a11y-deep)
  • fieldset-requires-legend — <fieldset> without <legend>. syntax · low-med. (a11y-deep)
  • no-filename-alt-text — alt="hero.png" / alt="IMG_1234" (filename as alt). syntax · low. (a11y-deep)
  • no-redundant-live-region — aria-live on a role="alert"/status (double-announce). syntax · low. (a11y-deep)

Security (mostly scan rules over config/non-linted files)

  • supabase-service-role-key-in-client — service-role key referenced in client code. scan · low. (security) — high severity.
  • firebase-admin-sdk-in-client — firebase-admin imported in client bundle. scope · low. (security) — high severity.
  • jwt-verify-unpinned-algorithm — jwt.verify without an algorithms allowlist. scope · med. (security; extends existing jwt-insecure-verification)
  • disabled-tls-certificate-validation — rejectUnauthorized: false / NODE_TLS_REJECT_UNAUTHORIZED=0. syntax · low. (security)
  • unsafe-native-deserialization — node-serialize.unserialize/vm.runIn… (untrusted). scope · med. (security)
  • client-controlled-open-redirect — location.href = <req/searchParams value>. path · med. (security)

Motion / design (design-deferred — verdict SHIP)

  • no-tailwind-will-change — static will-change-* class. syntax · med. (extends shipped inline no-permanent-will-change)
  • no-tailwind-long-duration — duration-[>1000ms] / duration-1000+ on a transition. syntax · low. (extends no-long-transition-duration to Tailwind)
  • no-animate-presence-child-without-key — conditional <motion.*> inside <AnimatePresence> lacking key (exit silently breaks). scope · med. (design-deferred, react-perf)
  • no-high-bounce-spring — framer transition={{ type:"spring", bounce:>0.3 }} on UI. scope · med. (design-deferred)
  • no-linear-ease-on-transition — ease-linear on a non-loop transition. syntax · med (loops). (design-deferred)
  • no-uppercase-subunit-leading — uppercase + leading-none/<1.0 (cap collision on wrap). syntax · low. (design-deferred, hallmark gate 55)

ui.sh components (Tailwind/structure)

  • no-unshrinkable-flex-icon — icon (svg/*Icon) in a flex row without shrink-0 next to text. scope · med. (uidotsh ★)
  • no-truncate-without-min-w-0 — truncate on a flex child without min-w-0 (won't truncate). scope · med. (uidotsh)
  • no-hover-on-non-interactive — hover:* styles on a non-interactive, role-less element. scope · med-high. (uidotsh)
  • no-xmlns-on-inline-svg — xmlns on an inline JSX <svg> (redundant bytes). syntax · low. (uidotsh)
  • design-no-emoji-in-jsx-text — emoji glyph as UI text/icon. syntax · med → default-off. (uidotsh, design-deferred)

React Native (Tier 1)

  • rn-animated-missing-use-native-driver — Animated.timing/spring without useNativeDriver. scope · low. (rn)
  • rn-no-virtualized-list-in-scrollview — <FlatList>/<FlashList> inside <ScrollView> (kills virtualization). scope · low. (rn)
  • rn-no-animated-value-in-render — new Animated.Value() in render body (use useRef). path · low. (rn)
  • rn-modal-missing-on-request-close — <Modal> without onRequestClose (Android back). syntax · low. (rn correctness)
  • rn-no-onpress-on-view — onPress on <View> (no-op; use Pressable). syntax · low. (rn)

Tier B — Solid-dialect family (product decision)

React Doctor currently skips Solid/Qwik files (react-jsx-only tag). eslint-plugin-solid is a ready-made, battle-tested rule set we could port behind a solid capability — a net-new surface, not a few rules. Highest-value: solid-no-destructure-props (breaks reactivity), solid-signal-read-uncalled-in-jsx ({count} vs {count()}), solid-no-react-specific-props (className/htmlFor→class/for), solid-no-react-deps (passing a deps array to createEffect), solid-prefer-for (<For> over .map), solid-no-derived-state-effect. Plus the framework-agnostic no-async-effect-callback (already in Tier S).

Already covered / drop (dedupe catches)

  • no-viewport-zoom-disabled → EXISTS (no-disabled-zoom).
  • no-set-state-as-dom-event-handler, no-side-effects-in-reducer, no-state-mutation-in-updater → overlap no-set-state-in-render / no-mutating-reducer-state family; verify before building.
  • no-tabular-nums-on-numeric-cell, no-nonconcentric-nested-radius → need cell-type/geometry; default-off at best.
  • next-image-dangerously-allow-svg, graphql-introspection-enabled-in-prod, cors-credentialed-origin-reflection → niche security scan; lower priority.

Skip — too noisy / not statically sound (from the deferred pass)

no-named-line-height (leading-tight is often fine), no-inline-static-style, no-arbitrary-over-bare-value (z-[999]→z-999), no-straight-quotes-in-jsx (apostrophes), no-cliche-purple-gradient (opinionated; default-off only), no-uniform-hover-scale (cross-element, not per-element), heading-order (~50% static precision).


no-call-component-as-function, no-async-effect-callback, no-json-parse-stringify-clone, no-create-ref-in-function-component, dialog-has-accessible-name, auth-token-in-web-storage, no-img-lazy-with-high-fetchpriority — all syntax/scope-only, low-FP, each grounded in ≥1 (mostly ≥2) sources and orthogonal to the existing rules. Implemented with adversarial tests + FP-regression coverage (incl. scope-resolution shadow-safety) on branch add-mined-correctness-perf-a11y-rules.

Dropped from the batch: img-missing-dimensions — a static linter cannot see CSS, and width/ height attributes are only one of several valid ways to reserve space (CSS aspect-ratio, container sizing, CSS width/height all work). Lighthouse's unsized-images audit makes this call at runtime against computed styles; a syntax rule would false-positive on every image sized by an external/global stylesheet. Moved to the not-statically-sound list below.

Mining batch 2 — SHIPPED (3) + evidence-based drops

Each shipped rule passed an OSS noise sweep (react-use, radix-ui/primitives, excalidraw, mantine; ~2,800 diagnostics): 0 false positives.

  • no-document-write (Performance) — document.write()/writeln().
  • no-sync-xhr (Performance) — .open(method, url, false) synchronous XHR.
  • no-string-false-on-boolean-attribute (Bugs) — disabled="false" etc. (truthy string applies the boolean). Curated boolean-attr set on intrinsic elements only; excludes aria-*/enumerated.

Dropped after the sweep (real but too noisy / FP-prone):

  • no-accumulator-spread-in-reduce — 29 hits across the 4 repos, all idiomatic small-data style merges ([...styles].reduce((acc,i)=>({...acc,...}))). The pattern is genuinely O(n^2) but a static rule can't tell a 3-item merge from a 10k-row one, so default-on is too noisy. Revisit as opt-in / default-off with a large-source heuristic.
  • no-redundant-live-region — both real-world hits were false positives: an intentional role="status" + dynamic aria-live override (radix toast) and a defensive explicit aria-live="polite" (mantine carousel). axe does not flag this pattern; the "redundant" framing is contentious. Dropped.

Raw per-cluster candidate detail (bad/good for all ~200) was produced in /tmp/rd-mine/*.md (ephemeral). Ask to persist any cluster's full detail into the repo if needed.