Files
millionco__react-doctor/docs/react-router-rule-research.md

1418 lines
81 KiB
Markdown

# React Router rule research
Research date: 2026-07-19
Status: implemented and validated on the accompanying branch.
## Outcome
React Router is a strong candidate for a dedicated React Doctor family, but it cannot be treated as
one framework-shaped API. Its official agent skill separates Framework, Data, and Declarative
modes, and the same distinction is necessary for sound linting. In particular, loaders, actions,
fetchers, `route.lazy`, and route-module exports do not exist in Declarative mode.
The first pass found 15 candidates. A second pass through the router runtime invariants, middleware
implementation, v6/v7/v8 changelogs, security advisories, Framework conventions, accepted design
decisions, and maintainer threads expands the backlog to 38 candidates. The P0 tier is now 12 rules
whose harmful behavior is directly provable and whose version boundary can be encoded; the table
retains the original P1/P2 context:
| Priority | Candidate | Modes | Precision | Confidence |
| -------- | ------------------------------------------------------- | -------------------- | --------------- | ----------------------------- |
| P0 | `react-router-no-router-in-render` | Data | scope + path | high |
| P0 | `react-router-no-navigate-in-render` | all | path | high |
| P0 | `react-router-no-unsynchronized-search-params-mutation` | all | scope + path | high |
| P0 | `react-router-no-multiple-set-search-params-in-tick` | all | path | high |
| P0 | `react-router-no-invalid-lazy-route-properties` | Data | scope | high |
| P0 | `react-router-nested-route-requires-outlet` | all | cross-file | high when resolved |
| P0 | `react-router-no-loader-request-body` | Framework, Data | scope | high |
| P0 | `react-router-no-session-mutation-in-loader` | Framework | scope | high |
| P0 | `react-router-no-use-loader-data-in-error-ui` | Framework, Data | scope + project | high |
| P0 | `react-router-no-multiple-middleware-next` | Framework, Data | path | high |
| P0 | `react-router-descendant-routes-require-splat` | all | cross-file | high when resolved |
| P0 | `react-router-no-invalid-absolute-child-path` | all | project | high when static |
| P1 | `react-router-require-root-error-boundary` | Framework, Data | project | high |
| P1 | `react-router-guard-aborted-handle-error` | Framework | path | high |
| P1 | `react-router-resource-link-requires-reload` | Framework, Data | project + scope | high when resolved |
| P1 | `react-router-loader-parallel-fetch` | Framework, Data | path | high |
| P1 | `react-router-loader-fetch-forwards-signal` | Data, client loaders | scope | medium-high |
| P1 | `react-router-prefer-route-lazy` | Data | scope | medium-high |
| P2 | `react-router-internal-route-anchor` | all | project + scope | medium-high when resolved |
| P2 | `react-router-csp-nonce-consistency` | Framework | cross-file | medium-high |
| P2 | `react-router-valid-route-object` | Data | scope | high, but mostly type-covered |
The number is intentionally not the goal. The expansion is useful because it separates three
different products that should not be conflated:
- default-on behavioral rules with direct runtime or security consequences;
- version-scoped migration rules that only activate for a declared target major;
- dependency/configuration diagnostics better implemented in the existing supply-chain or project
scanners than as AST lint rules.
“When resolved” is load-bearing. The project-aware rules should remain silent when React Doctor
cannot prove the route target or component implementation. A missing diagnostic is preferable to
guessing that an anchor points to a UI route, that an imported component omits an outlet, or that a
URL is a resource route.
## Implementation status
All 38 source diagnostics in this document are registered. Project discovery resolves
`@react-router/dev`, `react-router-dom`, and `react-router`, emits monotonic capabilities at each
release boundary used by a rule, distinguishes Framework mode from non-Framework package use, and
chooses the lowest parseable version in a mixed-version workspace. An unparseable workspace or
catalog spec receives only the bare `react-router` capability, so version-specific rules fail
closed. Data- and Declarative-mode rules require local syntax proof from imported APIs instead of a
project-wide mode guess. Every rule also checks the nearest package boundary before creating
visitors.
The implementation deliberately constrains cross-file contracts to same-file static evidence:
inline route objects, inline component bodies, literal destinations, proven imports, and recognized
Framework entry/config filenames. It does not guess through opaque route factories, imported
components, dynamic options, or arbitrary helper calls. Dependency advisories remain in the
existing supply-chain scanner instead of being duplicated as AST rules.
Validation covered the plugin's full regression suite, strict invariant fuzzing, a 100-repository
cross-framework corpus, a React Router 6.30 SPA, a 7.0 Data-mode app, a 7.16 Framework app, the
official stable-middleware playground, and the official v8 template. The field pass narrowed two
detectors before release: loader parallelization now requires independent native `fetch` calls,
and environment-suffixed route modules require a default route component rather than any colocated
named export.
## Required foundation
### Mode-aware capability detection
The shipped foundation uses project capabilities only where package evidence is reliable:
- `react-router`: `react-router` or `react-router-dom` is installed.
- `react-router-framework`: `@react-router/dev` is installed.
- monotonic version tokens: `react-router:6.4` through `react-router:8` at the release boundaries
used by the rules.
The official skill provides the exact mode signals and warns not to apply Framework/Data guidance
to Declarative apps. Data and Declarative usage are therefore proven locally from route creators,
route objects, JSX routers, and imported APIs. Filename-sensitive Framework rules stay limited to
canonical files; resolving custom `appDirectory` and arbitrary route configs remains future
cross-file-index work.
Source compatibility matters:
- React Router 6 applications commonly import DOM APIs from `react-router-dom`.
- React Router 7 collapsed the APIs into `react-router` and `react-router/dom` while retaining the
compatibility package.
- React Router 8 removed `react-router-dom`.
Rules should resolve imports from both packages, then use the installed major only where behavior
actually differs. The current documentation is v8.2.0, while the contracts below intentionally
target stable Data APIs from v6.4 onward and Framework APIs from v7 onward.
### Version resolver and rule gates
Version-aware does not mean checking the import spelling. The shipped resolver reads declared
versions and catalog references from root and workspace manifests, chooses the lowest parseable
version conservatively, and treats an unresolved or non-semver dependency as unknown. A rule may
then choose one of four policies:
1. **API-presence gate:** activate from the first version that shipped the API.
2. **Behavior gate:** activate only from the release that made the diagnosed behavior true.
3. **Target-major migration gate:** activate only when the user explicitly targets the next major,
or when the installed major has already removed the construct.
4. **Advisory gate:** compare the installed package to a patched range in the supply-chain scanner;
do not pretend a vulnerable dependency is a source-code lint problem.
| Feature or behavior | v6 | v7 | v8 | Rule consequence |
| -------------------------------------- | ---------------------------------- | --------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Data routers, loaders, actions | `>=6.4` | supported | supported | Data-only rules require Data/Framework proof, not merely a package import. |
| `route.lazy` function | `>=6.9` | supported; object form added later | supported | Lazy-property and route-lazy rules stay off below 6.9. |
| `useBlocker` | unstable `>=6.7`, stable `>=6.19` | supported | supported | Multiple-blocker rule recognizes both names only in their valid ranges. |
| Search-param setter callback isolation | shared mutable instance before 7.7 | copied callback value from `7.7.0` | copied value | The mutation rule follows only the tuple result, never the setter callback parameter, so the same detector is valid across versions. |
| Middleware | absent | unstable before 7.9; stable `>=7.9` | always enabled | Stable middleware rules gate at 7.9; experimental middleware remains outside the first shipped contract. |
| `next()` never throws | absent | true from `7.8.0` | true | The try/catch-around-`next` rule must stay off for older experimental middleware. |
| React transition router option | absent | `unstable_useTransitions` from 7.10; `useTransitions` from 7.15 | supported as `useTransitions` | Promise-return rule uses the correct prop name and only treats Data/Framework navigation APIs as promise-returning. |
| Framework root `Layout` error flow | absent | supported | supported | Loader-data-in-error-UI applies to Framework root `Layout` and route boundaries from v7. |
| Package entry points | `react-router-dom` normal | compatibility export retained | `react-router-dom` removed | The import migration rule activates only for an installed v8; maintained v6/v7 apps remain quiet. |
| `meta`/`useMatches` match data | `data` | `data` deprecated from 7.8; `loaderData` available | `data` removed | The removed-field rule activates only for an installed v8 and requires scope or Framework-export proof. |
| `future.v8_*` flags | absent | opt-in upgrade controls | removed or promoted | The removed-flag rule activates only for an installed v8 Framework config. |
Two important non-rules follow from this matrix:
- Do not run a v8 codemod-style diagnostic merely because current documentation prefers the v8
spelling. A maintained v6 application is correct to import from `react-router-dom`.
- Do not fork every behavioral detector by major. Most route-tree invariants are unchanged from
v6 through v8; attach a minimum version only when the API or behavior genuinely differs.
### Route and component index
Three candidates need a small cross-file index:
- route parent -> component/module;
- route path -> UI route or resource route;
- component -> directly or transitively renders `Outlet`/calls `useOutlet`.
Framework mode can derive this from `routes.ts`, `@react-router/fs-routes`, and the configured app
directory. Data mode can derive it from static route objects supplied to a data-router creator.
Declarative mode can derive it from `Route` elements. Dynamic route factories, runtime patching,
and opaque third-party configs stay unknown.
## Evidence base
### Primary React Router sources
- The [official React Router agent skill](https://github.com/remix-run/react-router/blob/main/.agents/skills/react-router/SKILL.md)
defines the mode boundary and says to use installed, version-matched docs. It was added to
`create-react-router` in v8.1.0.
- [Picking a Mode](https://reactrouter.com/start/modes) explains that Data mode moves route config
outside React rendering and adds loaders/actions/fetchers on top of Declarative mode.
- [Data-mode installation](https://reactrouter.com/start/data/installation) says to create a data
router once outside the React tree and not hold it in React state.
- [RouterProvider](https://reactrouter.com/api/data-routers/RouterProvider) explicitly warns against
creating new routers during renders and re-renders.
- [useSearchParams](https://reactrouter.com/api/hooks/useSearchParams) documents both the stable,
mutable object and the lack of React-style queueing for repeated setter calls in one tick.
- The [`route.lazy` design decision](https://github.com/remix-run/react-router/blob/main/decisions/0002-lazy-route-modules.md)
explains why path-matching fields cannot be returned by `lazy` and why static handlers can run in
parallel with lazy module loading.
- [Data routing](https://reactrouter.com/start/data/routing) states that child routes render through
the parent `Outlet` and that index routes cannot have children.
- [Error boundaries](https://reactrouter.com/how-to/error-boundary) says every application should
export at least a root error boundary and documents boundary bubbling.
- [`entry.server.tsx`](https://reactrouter.com/api/framework-conventions/entry.server.tsx) says
aborted requests generally should not be logged or reported from `handleError`.
- [Resource routes](https://reactrouter.com/how-to/resource-routes) require an anchor or
`Link reloadDocument`; an ordinary `Link` makes the router try to render the payload as UI.
- [Custom Data frameworks](https://reactrouter.com/start/data/custom) demonstrate forwarding
`request.signal` to a loader fetch.
- [Security](https://reactrouter.com/how-to/security) requires the same CSP nonce on
`ServerRouter` and the React streaming renderer.
- [Middleware](https://reactrouter.com/how-to/middleware) says `next()` may be called only once,
never throws from v7.8 onward, and requires the server middleware to return the response.
- [Sessions and Cookies](https://reactrouter.com/explanation/sessions-and-cookies) warns that
session mutation in a loader creates CSRF exposure, explains the required outgoing
`commitSession`/`destroySession` cookie, and recommends `maxAge` over a module-static expiry.
- [Root route convention](https://reactrouter.com/api/framework-conventions/root.tsx) forbids
`useLoaderData` in `ErrorBoundary` and extends the restriction to root `Layout`, which also
renders error flows.
- [Client modules](https://reactrouter.com/api/framework-conventions/client-modules) says exports
from `.client` modules are `undefined` on the server and should only be consumed in effects and
user-event callbacks.
- [Server modules](https://reactrouter.com/api/framework-conventions/server-modules) says route
modules cannot use `.server` or `.client` suffixes because the compiler needs them in both
graphs.
- [React Transitions](https://reactrouter.com/explanation/react-transitions) requires returning or
awaiting an imperative navigation promise inside `startTransition`.
- The accepted [Do not clone request decision](https://github.com/remix-run/react-router/blob/main/decisions/0002-do-not-clone-request.md)
defines loaders as GET/HEAD handlers with no request body.
- Runtime route conversion and matching enforce unique route IDs, valid absolute-child prefixes,
index routes without children, splats after `/`, one router, and one blocker.
- [Updating from v7](https://reactrouter.com/upgrading/v7) and the
[changelog](https://reactrouter.com/home/changelog) provide the v8 import, `loaderData`, future
flag, runtime baseline, and security-patch boundaries used by the version matrix.
### Maintainer issues, pull requests, and discussions
- [Discussion #10605](https://github.com/remix-run/react-router/discussions/10605) diagnoses duplicate
loaders under Strict Mode when `createBrowserRouter` runs in a component render and directs the
user to module scope.
- [Issue #11622](https://github.com/remix-run/react-router/issues/11622) led to the official warning
that the stable `searchParams` reference is mutable and can diverge from the URL.
- [Issues #14491](https://github.com/remix-run/react-router/issues/14491) and
[#14077](https://github.com/remix-run/react-router/issues/14077) confirm that repeated
`setSearchParams` calls in one tick intentionally do not compose like React state updates.
- [PR #10435](https://github.com/remix-run/react-router/pull/10435) fixed Strict Mode behavior for
`Navigate` in a data router. It is evidence not to ban `Navigate`; descendant `Routes` in a Data
app still do not participate in loaders and may need the component.
- [Discussion #10465](https://github.com/remix-run/react-router/discussions/10465) recommends
`route.lazy` over `React.lazy` for a data route because the latter introduces a render-fetch
waterfall.
- [Remix issue #5846](https://github.com/remix-run/remix/issues/5846) contains a concrete nested
route that never renders because its parent omits `Outlet`.
- [PR #12601](https://github.com/remix-run/react-router/pull/12601) added the nuanced
`clientLoader.hydrate`/`HydrateFallback` documentation; this evidence rules out an unconditional
“hydrate requires fallback” rule.
- [Discussion #9841](https://github.com/remix-run/react-router/discussions/9841) and the router's
own warning demonstrate that a component-owned descendant `Routes` tree requires the parent path
to end in `/*`.
- [PR #14118](https://github.com/remix-run/react-router/pull/14118) changed middleware error
handling in v7.8 so `next()` returns a boundary response instead of throwing.
- [PR #14215](https://github.com/remix-run/react-router/pull/14215) stabilized middleware and
context APIs in v7.9.
- [PR #14524](https://github.com/remix-run/react-router/pull/14524) introduced transition-enabled
routers in v7.10, and [PR #14999](https://github.com/remix-run/react-router/pull/14999) stabilized
the `useTransitions` spelling in v7.15.
- The accepted [useBlocker decision](https://github.com/remix-run/react-router/blob/main/decisions/0001-use-blocker.md)
explicitly limits a router to one active blocker.
### Skills and ecosystem review
- The official skill is mode- and version-aware. Its strongest lintable guidance is represented in
this backlog: router creation outside render, loader/action data flow, outlet nesting, route lazy
loading, URL parsing, and root error handling.
- The community
[frontend React Router best-practices skill](https://github.com/sergiodxa/agent-skills/blob/main/skills/frontend-react-router-best-practices/SKILL.md)
supplied useful seeds around parallel loaders, abort signals, resource routes, forms/fetchers,
redirects, and hydration. Its organizational and preference-level advice was not treated as a
rule contract without primary corroboration.
- The old `eslint-plugin-react-router` package predates modern data routers. Putout offers React
Router transforms, and TanStack Router has a router-specific ESLint plugin, but there is no
mature modern React Router lint suite covering these contracts. React Doctor would be filling a
real gap rather than cloning an established ruleset.
## P0 contracts
### `react-router-no-router-in-render`
Rule definition:
This rule catches a data-router instance created inside a component or hook render lifecycle and
passed to `RouterProvider`, which recreates router state and can rerun loaders.
Runtime reason:
A data router owns navigation, loader, fetcher, and subscription state. Recreating it during React
render discards that state; Strict Mode can create it twice and execute initial loaders twice.
Detector precision:
Scope-aware plus render-path analysis.
Evidence:
- Data installation and `RouterProvider` require one instance outside the React tree.
- Discussion #10605 reproduces duplicate loaders and the maintainer identifies render-time router
construction as the cause.
Strong positives:
- `const router = createBrowserRouter(routes)` directly in `App`, returned through
`<RouterProvider router={router} />`.
- A router held in `useState` or `useMemo` and passed to `RouterProvider`; the official docs reject
React-owned router lifetime, not just the direct-call spelling.
- Aliased imports of `createBrowserRouter`, `createHashRouter`, or `createMemoryRouter`.
False-positive traps:
- Module-scope router construction is correct.
- `createStaticRouter` per server request is correct and is not part of this rule.
- A factory mentioned inside a component but only executed by an unrelated deferred callback and
never supplied to `RouterProvider` should stay quiet.
- Test helpers may intentionally create isolated memory routers; either exclude test-like files or
tag the rule for test noise.
In scope:
- Proven React Router creator imports whose result reaches the provider in the same file.
- Direct calls and React initializer callbacks.
Out of scope:
- Opaque custom router factories, cross-file value flow, and server static routers in v1.
Test seeds:
- Invalid: direct render call, aliased creator, `useMemo`, `useState` initializer, helper invoked on
the render path.
- Valid: module scope, server request `createStaticRouter`, unused deferred callback, shadowed local
`createBrowserRouter`.
Open questions:
- Whether test files should be excluded or reported under `test-noise`.
### `react-router-no-navigate-in-render`
Rule definition:
This rule catches the function returned by `useNavigate` being invoked on the synchronous render
path, which causes navigation as a render side effect.
Runtime reason:
Render can restart or execute more than once. A navigation there can duplicate loader work,
produce hydration inconsistencies, or trigger update-during-render failures.
Detector precision:
Scope-aware path analysis.
Evidence:
- `useNavigate` is documented for user interactions or effects.
- Discussion #10605 recommends links or loader redirects instead of navigation from render.
Strong positives:
- `if (!user) navigate("/login")` in a component body.
- A local helper called synchronously from render that invokes the proven navigate binding.
- Calls inside an IIFE, array iterator, or `startTransition` executed during render.
False-positive traps:
- Event handlers, effects, promise continuations, timers, and callbacks returned from custom hooks.
- `<Navigate>` is a separate supported API and must not be flagged.
- A locally declared or shadowed function named `navigate`.
In scope:
- Named, aliased, and namespace imports of `useNavigate` from supported React Router packages.
- Direct and transitively invoked local synchronous functions.
Out of scope:
- Opaque imported helpers and deciding whether an effect should be converted to a loader redirect.
Test seeds:
- Invalid: direct conditional call, IIFE, called helper, `forEach`, `startTransition`.
- Valid: click handler, effect, timeout, `.then`, returned hook callback, shadowed hook/function.
Open questions:
- None. Reuse the hardened deferred-callback analysis from
`tanstack-start-no-navigate-in-render`.
### `react-router-no-unsynchronized-search-params-mutation`
Rule definition:
This rule catches mutation of the `URLSearchParams` object returned by `useSearchParams` when the
same synchronous update does not call its paired setter, leaving the object and URL out of sync.
Runtime reason:
The hook returns a stable but mutable reference. Mutating it can change values observed on a later
render even though the browser URL never changed.
Detector precision:
Scope-aware path analysis.
Evidence:
- The current hook documentation states the exact object/URL divergence.
- Issue #11622 records the maintainer clarification that became the documentation.
Strong positives:
- `searchParams.set`, `append`, `delete`, or `sort` on the first tuple binding with no paired
`setSearchParams` call in the same synchronous update.
- Mutation through a local alias of the proven hook result.
False-positive traps:
- Mutating the callback parameter inside `setSearchParams(previous => { ...; return previous })`
is an official supported example.
- `const next = new URLSearchParams(searchParams); next.set(...)` is valid.
- Mutating the outer object and then passing it to the paired setter in the same path updates the
URL and should stay quiet, even if cloning is stylistically cleaner.
- An unrelated `URLSearchParams` instance or shadowed `useSearchParams`.
In scope:
- Direct mutating methods on the proven first tuple element and simple local aliases.
- Pairing with the second tuple element from the same hook call.
Out of scope:
- Mutation hidden in imported helpers or passed through arbitrary object graphs.
Test seeds:
- Invalid: each mutator without setter, alias mutation, mutation in a later event callback without
setter.
- Valid: setter callback mutation, clone mutation, mutation followed by paired setter, read-only
methods, shadowed hook.
Open questions:
- Whether mutation followed by the setter only on some control-flow branches should report at the
mutation or at the unpaired exit.
### `react-router-no-multiple-set-search-params-in-tick`
Rule definition:
This rule catches two reachable calls to the same `setSearchParams` binding in one synchronous
execution segment, where later navigation overwrites rather than composes with the earlier update.
Runtime reason:
Unlike React state, functional search-param updates are not queued. Multiple calls in the same tick
start from the same location and do not build on one another.
Detector precision:
Path-aware.
Evidence:
- The behavior is an explicit warning in the hook documentation.
- Issues #14491 and #14077 were closed as expected behavior and point users to one combined update
or intermediate React state.
Strong positives:
- Two setter calls in sequence in one event/effect/function.
- A setter call followed by a synchronously invoked local helper that calls the same binding.
False-positive traps:
- Calls in mutually exclusive branches.
- A later call after `await`, in a timer, or in a promise continuation.
- Calls to setters from different `useSearchParams` instances.
- A deliberately redundant first call is dead but not necessarily a router correctness issue; v1
may still report because its result is provably discarded, with a precise message rather than a
claim about user intent.
In scope:
- Same-file direct calls and synchronously invoked local helpers.
- One diagnostic on the later call.
Out of scope:
- Cross-module helper effects and scheduling through unknown APIs.
Test seeds:
- Invalid: sequential object updates, sequential functional updates, called local helper.
- Valid: combined callback, exclusive branches, post-`await`, timeout, different setters.
Open questions:
- Whether to begin with direct same-function calls only, then add local helper expansion after OSS
evaluation.
### `react-router-no-invalid-lazy-route-properties`
Rule definition:
This rule catches a Data route's `lazy` result attempting to define `path`, `index`,
`caseSensitive`, `children`, or `id`, which React Router cannot apply after matching.
Runtime reason:
React Router must match and uniquely identify a route before it knows which `lazy` function to
load. Returning matching fields is too late and is ignored with a warning.
Detector precision:
Scope-aware.
Evidence:
- The route-lazy design decision names the immutable fields and explains the ordering constraint.
- `createBrowserRouter` documentation keeps route definitions such as path/index available up
front while lazily loading implementation fields.
Strong positives:
- A route object passed to a data-router creator whose inline `lazy` function returns any forbidden
field.
- A `lazy: () => import(...)` module that can be resolved locally and exports one of the forbidden
route fields.
False-positive traps:
- An arbitrary object with a property named `lazy`.
- Valid lazy fields such as `Component`, `ErrorBoundary`, `loader`, `action`, `handle`, and
`shouldRevalidate`.
- Static route fields beside `lazy` are correct.
In scope:
- Inline object returns first; locally resolvable imported route modules as a follow-up.
Out of scope:
- Dynamic imports whose exports cannot be statically resolved.
Test seeds:
- Invalid: each forbidden field, async return, aliased data-router creator.
- Valid: forbidden fields on the static route object, valid implementation fields from lazy,
unrelated `lazy` object, shadowed creator.
Open questions:
- None for inline returns.
### `react-router-nested-route-requires-outlet`
Rule definition:
This rule catches a route that both renders a parent component and owns child routes when the
resolved parent component never renders `Outlet` and never calls `useOutlet`.
Runtime reason:
Matched child UI is inserted only at the parent's outlet. Without one, the URL can match while the
child is never visible.
Detector precision:
Project-aware cross-file resolution.
Evidence:
- Data routing documents the outlet as the child render point.
- Remix issue #5846 demonstrates a matched nested route hidden by a parent without an outlet.
Strong positives:
- A Data route object with `Component` plus `children`, where the local component tree contains no
outlet.
- A declarative parent `Route element={<Layout />}>` with nested `Route` children and a resolvable
outlet-free layout.
- A Framework route module used as a parent in `routes.ts` with no transitive outlet.
False-positive traps:
- A prefix route with children but no component needs no outlet.
- A component that delegates to a local/imported child which renders `Outlet` is valid.
- `useOutlet()` rendered as a value is valid.
- An opaque external component must remain unknown and quiet.
In scope:
- Statically indexed routes and components, with transitive local component traversal.
Out of scope:
- Runtime-patched routes, arbitrary route factories, and unresolved external layouts.
Test seeds:
- Invalid: local parent without outlet in all three modes, fragment-only layout, unrelated imported
symbol named Outlet.
- Valid: direct Outlet, aliased Outlet, `useOutlet`, delegated local child, prefix route, leaf route,
unresolved external component.
Open questions:
- Set a conservative traversal depth and cycle handling policy for delegated component trees.
## P1 contracts
### `react-router-require-root-error-boundary`
Rule definition:
This rule catches a Framework or Data application whose statically known root route coverage has no
React Router error boundary.
Runtime reason:
Without an application boundary, unexpected loader, action, lazy-module, and render errors fall to
the minimal built-in UI instead of an application-owned recovery surface.
Detector precision:
Project-aware.
Evidence:
- The error-boundary guide says every application should at minimum export a root boundary and
shows both Framework and Data forms.
Strong positives:
- Framework `root.*` without named `ErrorBoundary` export.
- A Data router with a single root route lacking `ErrorBoundary`/`errorElement`.
- Multiple top-level Data branches where at least one branch has no ancestor boundary covering it.
False-positive traps:
- Nested routes do not each need a boundary; errors bubble to the nearest ancestor.
- Declarative `Routes` do not participate in data-router error handling.
- A custom Framework app directory must be honored.
In scope:
- Framework root modules and static Data route trees.
Out of scope:
- Runtime-patched route trees and generic React class error boundaries not registered with the
router.
Test seeds:
- Invalid: Framework root without export, Data root without either boundary form, uncovered second
root branch.
- Valid: named Framework export, Data `ErrorBoundary`, Data `errorElement`, nested routes covered by
an ancestor, Declarative app.
Open questions:
- Whether the first release should support only Framework mode for a simpler, zero-ambiguity
project check.
### `react-router-guard-aborted-handle-error`
Rule definition:
This rule catches known logging or error-reporting sinks in Framework `handleError` that are not
dominated by a `request.signal.aborted` guard.
Runtime reason:
React Router aborts interrupted requests as normal concurrency behavior. Reporting those requests
creates false incidents and noisy logs.
Detector precision:
Path-aware.
Evidence:
- The `entry.server` contract explicitly recommends skipping aborted requests and provides the
guard.
Strong positives:
- Unconditional `console.error`, Sentry `captureException`, or a known logger/reporting import in
exported `handleError`.
- Reporting in a branch that can still execute when `request.signal.aborted` is true.
False-positive traps:
- `if (request.signal.aborted) return` before the sink.
- `if (!request.signal.aborted) { report(error) }`.
- Pure formatting, metrics explicitly describing abort volume, and an unrelated function named
`handleError` outside the server entry/module contract.
In scope:
- Framework server entry files and typed/named `handleError` exports; global `console.error` and
Sentry reporting imports. Nested function bodies are excluded unless execution is proven.
Out of scope:
- Unknown side-effect functions whose reporting semantics cannot be established.
Test seeds:
- Invalid: unguarded console, Sentry, logger import, partial guard.
- Valid: positive guard, early return, no sink, client `onError`, shadowed request/signal.
Open questions:
- Curate the initial reporting-sink imports from existing React Doctor telemetry knowledge rather
than guessing from function names alone.
### `react-router-resource-link-requires-reload`
Rule definition:
This rule catches `Link` or `NavLink` client navigation to a statically known resource route without
`reloadDocument`.
Runtime reason:
A resource route returns a file or raw response, not route UI. Client routing tries to fetch and
render that payload through the data-router protocol and fails.
Detector precision:
Project-aware plus scope resolution.
Evidence:
- The resource-route guide explicitly requires `<a>` or `<Link reloadDocument>`.
- PR #8283 introduced the intentional full-document escape hatch.
Strong positives:
- A literal or statically generated `to` that resolves to a route module with loader/action but no
default component.
- A Data route object target with a loader/action and no render property.
False-positive traps:
- Native anchors are correct for resource routes.
- `reloadDocument`, external URLs, unresolved dynamic targets, and UI routes.
- A path-only parent route with children is not a resource route.
In scope:
- Exact static targets proven by a loader/action-only Data route object in the same module. This
conservative first release requires React Router 6.4 or newer.
Out of scope:
- Cross-file Framework route discovery, parameterized targets, runtime-generated routes, and
arbitrary string construction. These require a project route index before they can be enabled
without reviving the file-extension false positive.
Test seeds:
- Invalid: Link/NavLink to static and parameterized resource paths.
- Valid: native anchor, reloadDocument, UI route, external URL, unresolved target, shadowed Link.
Open questions:
- Whether Data-mode resource classification should require the loader to return a proven
`Response`, reducing coverage but avoiding component-less prefix routes.
### `react-router-loader-parallel-fetch`
Rule definition:
This rule catches independent sequential awaits in a route loader or client loader that create an
avoidable request waterfall.
Runtime reason:
Route rendering waits for loader completion. Independent work serialized by separate awaits adds
latencies instead of taking their maximum.
Detector precision:
Path-aware dependency analysis.
Evidence:
- Route object documentation demonstrates `Promise.all` inside lazy loading, and React Router runs
matched loaders in parallel.
- The official and community skills recommend parallel independent loader work.
Strong positives:
- Two or more pairwise-independent top-level awaits in a Framework `loader`/`clientLoader`.
- Independent awaits in a loader property of a proven Data route.
False-positive traps:
- The second operation consumes data, or a transitively derived binding, from the first.
- Sequential mutation/transaction semantics.
- Nested callbacks that are already scheduled concurrently.
In scope:
- Reuse and generalize the dependency-aware implementation in
`tanstack-start-loader-parallel-fetch` rather than fork it.
Out of scope:
- Imported helper internals and performance claims about a single await.
Test seeds:
- Invalid: independent fetches, aliased route exports, three awaits with an independent sibling
pair.
- Valid: direct/transitive dependency, control-flow alternatives, `Promise.all`, non-loader.
Open questions:
- Prefer a shared `loader-parallel-fetch` detector with framework-specific entry recognizers over a
copy of the TanStack rule.
### `react-router-loader-fetch-forwards-signal`
Rule definition:
This rule catches a direct `fetch` in a Data loader or browser-executed client loader that receives
the route `request` but does not forward its abort signal.
Runtime reason:
React Router cancels interrupted navigations. Without `request.signal`, the underlying request
continues consuming network/server resources after its result is no longer usable.
Detector precision:
Scope-aware.
Evidence:
- The custom Data framework guide's loader fetch explicitly uses `{ signal: request.signal }`.
- React Router's race-condition model and server request APIs propagate abortable Fetch Requests.
Strong positives:
- `fetch(url)` or `fetch(url, { ...options })` in a proven loader that destructures/receives
`request` but omits its signal.
- Aliased request parameters and namespace/global fetch.
False-positive traps:
- `fetch(request)` already carries the request signal.
- `fetch(new Request(url, { signal: request.signal }))` and composed option objects proven to carry
it.
- Server-only database calls are not fetch.
- Fire-and-forget mutations have different cancellation semantics; v1 should target loaders and
client loaders, not actions.
In scope:
- Direct native fetch and statically inspectable options.
Out of scope:
- Axios/custom clients, opaque wrappers, actions, and cases where no route request parameter is
available.
Test seeds:
- Invalid: missing second arg, options without signal, unrelated signal.
- Valid: request signal, `fetch(request)`, Request wrapper, database call, action, custom client.
Open questions:
- Whether Framework server loaders should be included immediately or only where the downstream
fetch is known to honor AbortSignal.
### `react-router-prefer-route-lazy`
Rule definition:
This rule catches a Data route component supplied through a binding created by `React.lazy` when
the route could load its implementation with `route.lazy` before render.
Runtime reason:
`React.lazy` begins loading at render time, after route matching/data work, creating a render-fetch
waterfall. `route.lazy` lets the router load route implementation before rendering and coordinate it
with data work.
Detector precision:
Scope-aware.
Evidence:
- Maintainer guidance in discussion #10465 explicitly recommends `route.lazy` for this reason.
- The route-lazy design and official examples load route implementation fields together.
Strong positives:
- A `React.lazy` binding used as `Component`/`element` in a static route object passed to a data
router.
- The equivalent JSX route produced by `createRoutesFromElements` and passed to a data router.
False-positive traps:
- Declarative `Routes` cannot use data-router route lazy; `React.lazy` is valid there.
- A lazy component nested inside a route component is not itself the route implementation.
- Framework mode already performs route-module code splitting and should not receive Data-mode
rewrite advice.
In scope:
- Proven React `lazy` bindings used directly as Data route render fields.
Out of scope:
- Custom lazy wrappers and deciding whether a small route is worth splitting.
Test seeds:
- Invalid: Component and element forms, aliases, createRoutesFromElements feeding a data router.
- Valid: Declarative Route, nested lazy child, Framework route module, ordinary component, shadowed
lazy.
Open questions:
- Confirm the diagnostic can offer a safe non-autofix migration shape for both default and named
module exports.
## P2 and conditional candidates
### `react-router-internal-route-anchor`
Only report a literal/static native anchor when the route index proves it targets a UI route.
Exclude resource routes, external/protocol-relative URLs, downloads, `_blank`, same-document hashes,
and explicit full-reload conventions. The existing TanStack anchor rule has useful syntax handling,
but a React Router version without route-target proof would false-positive on the exact native
anchor required for resource routes.
### `react-router-csp-nonce-consistency`
When a Framework server entry creates or receives a CSP nonce, require the same value to reach both
`ServerRouter nonce` and the `nonce` option of `renderToPipeableStream` or
`renderToReadableStream`. Do not require nonce props merely because `Scripts` or
`ScrollRestoration` exists; a project without nonce-based CSP is valid, and current Framework mode
inherits the `ServerRouter` nonce for those components. Pair each render call with the single
`ServerRouter` in its render argument, and treat immutable identifier aliases as the same nonce.
### `react-router-valid-route-object`
For proven Data route objects, catch API contradictions that JavaScript users otherwise encounter
at runtime: an index route with `children`, `element` with `Component`, `errorElement` with
`ErrorBoundary`, and `hydrateFallbackElement` with `HydrateFallback`. A path is valid on the
router's current `IndexRouteObject` and is not a contradiction. Keep this below the behavioral rules
because TypeScript already rejects most cases. `lazy` immutable fields remain a separate rule
because they are structurally valid objects whose values are ignored too late. Match the router's
truthiness semantics: explicit `undefined`, `null`, `false`, `0`, empty-string, and `void` values do
not create a contradiction.
## Additional high-confidence contracts
### `react-router-no-loader-request-body`
Rule definition:
Report body-consuming methods on the loader's proven `request` binding. React Router loaders are
GET/HEAD handlers; request parsing belongs in an action.
Runtime reason:
The accepted request decision removes the body before loaders run. Calls such as
`request.formData()` or `request.json()` cannot recover data that is not present and usually signal
that a mutation endpoint was placed in the wrong route API.
Detector precision:
Scope-aware. Start at v6.4 for Data mode and v7 for Framework route modules.
Strong positives:
- `request.formData()`, `json()`, `text()`, `blob()`, or `arrayBuffer()` on the destructured loader
request.
- The same request binding passed to a local helper whose body immediately consumes it, when the
existing cross-file probe can resolve that helper.
False-positive traps:
- Body reads in `action`, `clientAction`, a resource handler for a mutation method, custom server
middleware, or an unrelated function named `loader`.
- `request.url`, headers, method, signal, or cloning for a non-body reason.
- A framework abstraction whose own `request` is not the React Router loader argument.
Implementation boundary:
No autofix. Moving code from a loader to an action changes the HTTP and UI contract.
### `react-router-no-session-mutation-in-loader`
Rule definition:
Report proven session writes in a Framework loader: `session.set`, `session.unset`,
`session.flash`, or a loader return that destroys the incoming session.
Security reason:
Loaders answer GET requests and can be triggered cross-site. React Router's session guide explicitly
warns that logout or any session mutation in a loader creates CSRF exposure.
Detector precision:
Scope-aware provenance from `getSession` or a known React Router session-storage factory. Framework
mode only by default.
Strong positives:
- A session obtained from `getSession(request.headers.get("Cookie"))` and mutated inside `loader`.
- `destroySession(session)` returned from a loader response.
- A loader helper that mutates the same resolved session object.
False-positive traps:
- `session.get`, `session.has`, auth checks, or redirecting an already-authenticated user.
- All mutation in actions.
- Reading a flash value. This can race across nested loaders, but reading is not the CSRF issue and
should be a separate conditional diagnostic if pursued.
- A method named `set` on an unrelated object.
Implementation boundary:
No autofix. The safe repair generally adds a POST form/action and may affect progressive
enhancement.
### `react-router-no-use-loader-data-in-error-ui`
Rule definition:
Report `useLoaderData()` inside a proven route `ErrorBoundary` and inside the Framework root
`Layout`. Recommend `useRouteLoaderData(routeId)` or generated boundary props and require the
possibly-undefined result to be guarded.
Runtime reason:
The failing loader may be the operation that selected the boundary, so happy-path loader data may
not exist. Root `Layout` also renders `ErrorBoundary` children; if it throws while reading absent
data, React Router falls back to its minimal built-in boundary.
Detector precision:
Scope plus route-module/export proof. Framework v7+ and statically associated Data boundaries.
Strong positives:
- A named `ErrorBoundary` export calling an imported or aliased `useLoaderData`.
- A root `Layout` export calling it directly or through a local custom hook that resolves to it.
- An `errorElement={<Boundary />}` whose resolved component calls the hook.
False-positive traps:
- The normal route component, `HydrateFallback`, or a component merely named `ErrorBoundary` with
no route association.
- `useRouteLoaderData("root")` followed by a guard.
- Loader data passed as an explicitly optional prop from a parent boundary.
Implementation boundary:
Do not offer a blind hook rename: the replacement needs a route ID and undefined handling.
### `react-router-no-multiple-middleware-next`
Rule definition:
Report a middleware function with two calls to its own `next` continuation that can execute on the
same path.
Runtime reason:
React Router throws `You may only call next() once per middleware` on the second call. `next` is a
single continuation through the remaining middleware and handler chain, not a reusable function.
Detector precision:
Scope-aware path analysis. Stable middleware from v7.9 and all v8; optionally recognize the
unstable v7 spelling only when its future flag is proven.
Strong positives:
- Two sequential `await next()` calls.
- One call before a branch and another on a reachable branch.
- Calling an alias of `next` and then `next` itself.
False-positive traps:
- Mutually exclusive branches where each complete path invokes `next` at most once.
- Nested callbacks that are never invoked by the middleware path.
- Two different middleware functions, each with its own continuation.
- An unrelated local function named `next`.
Implementation boundary:
No autofix. The second call may need deletion, or the middleware may need to reuse the first
`Response`.
### `react-router-no-catch-middleware-next`
Rule definition:
From v7.8 onward, report a try/catch whose protected operation is only `await next()` and whose
catch is intended to handle downstream route or middleware errors.
Runtime reason:
React Router catches downstream errors, renders the appropriate route boundary, and returns its
`Response` through `next()`. The catch is unreachable for those errors and creates a false sense of
recovery or logging coverage.
Detector precision:
Scope-aware syntax plus a hard behavior gate at v7.8. The narrow first version reports only when the
try body contains `await next()` with no other operation that can obviously throw.
Strong positives:
- `try { return await next() } catch (error) { report(error) }` in stable middleware.
- A catch that converts the supposed downstream error to a redirect or fallback response.
False-positive traps:
- Code in the try body before or after `next()` that can itself throw.
- Middleware versions before 7.8, where the experimental behavior differed.
- A catch around processing of the returned `Response`, such as parsing a header with an API that
can throw.
Implementation boundary:
Recommend an `ErrorBoundary`, `handleError`, or instrumentation according to intent; no autofix.
### `react-router-descendant-routes-require-splat`
Rule definition:
When a route component itself renders a new descendant `<Routes>` tree or calls `useRoutes`, require
the route that mounts that component to end its static path with `/*`.
Runtime reason:
Without the trailing splat, the parent stops matching at its own path and deeper descendant URLs
never reach the nested route tree. React Router emits this exact runtime warning.
Detector precision:
Cross-file route/component resolution. Applies from v6 onward in every mode.
Strong positives:
- `<Route path="account" element={<AccountRoutes />} />` where `AccountRoutes` renders `<Routes>`.
- A Data route `path: "account"` whose resolved `Component` calls `useRoutes`.
- Transitive local wrappers when the route index can prove the descendant tree is rendered.
False-positive traps:
- Ordinary nested `<Route>` children rendered through `Outlet`; those do not require this rule.
- A component that renders `Outlet` but no new `Routes`/`useRoutes` tree.
- Dynamic paths or opaque third-party components that cannot be resolved.
- A leaf route that merely imports `Routes` for tests or types.
Implementation boundary:
A safe suggestion can show `path="account/*"`, but avoid autofix because route-relative links and
tests may need coordinated updates.
### `react-router-no-invalid-absolute-child-path`
Rule definition:
Report a static absolute child path that does not begin with the fully combined path of its static
parents.
Runtime reason:
React Router throws during route flattening because an absolute child cannot escape the path where
it is nested.
Detector precision:
Project-aware route-tree evaluation. Applies from v6 onward.
Strong positives:
- Parent `/app` with nested child `/settings`.
- A nested route-object factory whose resolved absolute path ignores a static ancestor segment.
False-positive traps:
- Valid parent `/app` with child `/app/settings`.
- Relative child `settings`.
- Optional parent segments, pathless layouts, dynamically patched routes, and helper-produced paths
until the index models the router's own explode/flatten semantics exactly.
- Descendant `<Routes>` absolute-path limitations, which are related but are a separate contract.
Implementation boundary:
The first version should skip any ancestor containing optional or dynamic syntax it cannot normalize
with parity to the router implementation.
### `react-router-session-mutation-requires-commit`
Rule definition:
On each action exit path after a proven session mutation, require the resulting response to carry a
`Set-Cookie` value derived from `commitSession(session)`; after session destruction, require
`destroySession(session)` instead.
Runtime reason:
Session objects are in-memory request values. The mutation is not persisted until the serialized
cookie is included in the outgoing response.
Detector precision:
Scope-aware, path-sensitive provenance. Framework mode. Report only direct response construction
that the analyzer can follow.
Strong positives:
- `session.set(...)` followed by `return redirect("/")` with no headers.
- `destroySession(session)` is called but its result is ignored.
- One conditional return commits the session while another mutated path forgets it.
False-positive traps:
- A response helper whose resolved implementation adds the correct header.
- Database-backed session mutation still requires the cookie on create/delete, but existing-session
updates may have custom semantics; remain quiet when the storage implementation is opaque.
- A session read with no mutation.
- Returning a `Response` supplied by middleware that centrally commits the session.
Implementation boundary:
No autofix. The first release follows direct `commitSession(session)` results and immutable local
bindings into a `Set-Cookie` property inside the returned expression. Response helpers, mutable
header objects, and cross-function propagation stay unknown. Header merging is application-specific
and an incorrect fix can drop existing headers.
### `react-router-return-navigation-promise-in-transition`
Rule definition:
In a transition-enabled router, report a Data/Framework imperative navigation or submission promise
called inside `startTransition` without being returned or awaited.
Runtime reason:
React ends the transition when the callback completes. Dropping the navigation promise ends pending
and optimistic transition state before the navigation finishes.
Detector precision:
Scope-aware callback control flow plus project configuration. Recognize
`unstable_useTransitions` in v7.10-v7.14, `useTransitions` from v7.15 onward, and current v8.
The first release reports only when the module contains exactly one proven `RouterProvider` and it
enables transitions; mixed-provider modules are ambiguous and must be skipped.
Strong positives:
- Block callback with a bare `navigate("/next")` statement.
- An async callback that invokes but does not await `navigate`, `submit`, `fetcher.load`, or
`fetcher.submit`.
- A branch that returns the promise while another reachable branch drops it.
False-positive traps:
- Expression-bodied `() => navigate(...)`, which returns the promise.
- `return navigate(...)` or `await navigate(...)`.
- Declarative-mode `navigate` whose public return is `void`.
- A router without transition opt-in, unless a future v8 release makes the diagnosed behavior
unconditional.
- An unrelated `navigate` or `submit` function.
Implementation boundary:
An autofix from a single bare expression to `return` is possible only when it cannot change
subsequent callback execution; otherwise suggest the repair.
### `react-router-no-client-module-in-server-render`
Rule definition:
In an SSR/prerendered Framework app, report values imported from `.client` files or `.client/`
directories when they are consumed during module initialization or component render.
Runtime reason:
React Router replaces client-module exports with `undefined` in the server graph. Render-time reads
can crash SSR or produce hydration-divergent markup.
Detector precision:
Import-path plus render-path analysis, gated to non-RSC Framework SSR/prerendering.
Strong positives:
- Calling an imported `.client` function in the component body.
- Reading a `.client` feature-detection constant to choose rendered markup.
- A module-scope call or property access on a client-only import.
False-positive traps:
- Consumption inside `useEffect`, a click/change/submit handler, `clientLoader`, or `clientAction`.
- Passing the function itself as a user-event handler without evaluating it during render.
- SPA mode with no server/prerender graph.
- RSC Framework mode, which uses `"use client"`/boundary packages instead of the conventional
suffix semantics.
Implementation boundary:
No blind move-to-effect autofix; the correct server fallback is product-specific.
### `react-router-no-redirect-in-try-catch`
Rule definition:
Report `throw redirect(...)` or another proven thrown redirect response inside a try block when the
local catch can swallow or replace it. `return redirect(...)` remains valid and quiet.
Runtime reason:
A thrown redirect is control flow. A broad catch converts it into an error response, log, or
fallback, so the navigation never reaches React Router.
Detector precision:
Scope-aware thrown-value and catch-path analysis in loaders, actions, and middleware. Data v6.4+
and Framework v7+.
Strong positives:
- `try { throw redirect("/login") } catch { return data(...) }`.
- A helper call proven to throw a redirect inside a broad catch that never rethrows route responses.
False-positive traps:
- `return redirect(...)` in the try block.
- A catch that recognizes and rethrows the response before handling ordinary errors.
- Redirect construction outside the protected range.
- An unrelated function named `redirect`.
Reuse:
Generalize the existing Next.js and TanStack Start redirect-in-try-catch control-flow machinery;
only the redirect provenance and valid rethrow predicate should differ.
## Additional ranked backlog
These candidates have real evidence but either have a narrower payoff, are largely compiler-covered,
or need more project analysis before they should be default-on.
| Priority | Candidate | Gate | Contract and boundary |
| -------- | ------------------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 | `react-router-server-middleware-return-response` | Framework server middleware, stable >=7.9 or proven unstable opt-in | If server middleware calls `next()`, every reachable completion must return that `Response` or an explicit replacement. Client middleware may omit it. |
| P1 | `react-router-no-duplicate-route-id` | Data >=6.4 | Report duplicate static explicit IDs across the resolved route tree. Generated, dynamic, and opaque IDs stay unknown. The router throws on collisions. |
| P1 | `react-router-no-invalid-splat-path` | all, v6+ | Report static patterns such as `files*`; React Router warns and interprets them as `files/*`. Exempt `*` and valid `/*`. |
| P1 | `react-router-no-nested-router` | all, v6+ | Report an obvious router provider rendered beneath another router in the same resolved tree. Start with direct JSX nesting; do not guess through opaque component composition. |
| P1 | `react-router-no-multiple-blockers` | Data >=6.7; stable name >=6.19 | Report two unconditional `useBlocker` calls in the same component/render path. The router supports one active blocker. Avoid whole-project counting because mutually exclusive routes are valid. |
| P1 | `react-router-no-static-cookie-expires` | Framework sessions | Report `expires: new Date(Date.now() + ...)` in module-scope cookie/session-storage options. The date is fixed once per deployment; recommend `maxAge`. Do not flag a literal intentional cutoff date. |
| P1 | `react-router-no-route-module-environment-suffix` | non-RSC Framework v7+ | Report route files resolved from `routes.ts` whose filename/directory marks them `.server` or `.client`; the Framework build needs route modules in both graphs and fails. |
| P2 | `react-router-no-middleware-response-body-consumption` | server middleware >=7.9 | Warn when middleware consumes the `Response` returned by `next()` with `json`, `text`, `formData`, `blob`, or `arrayBuffer`. Header/status post-processing is supported; body consumption can make the outgoing body unusable. Keep conditional until runtime tests show zero legitimate patterns. |
| P2 | `react-router-no-empty-leaf-route` | static UI route tree, v6+ | Report a proven leaf UI route with no `element`, `Component`, or `lazy`; the router warns and renders a null outlet. Exclude resource routes, path prefixes, RSC routes, and intentionally componentless data routes. |
| P2 | `react-router-v8-no-react-router-dom-import` | installed v8 | Replace DOM APIs with `react-router/dom` and other APIs with `react-router`. Never report this in v6 or v7. |
| P2 | `react-router-v8-no-meta-data-field` | installed v8 | Report proven `data` bindings in `meta` args and `useMatches()` results; use `loaderData`. Scope proof is required because ordinary objects may have `data`. |
| P2 | `react-router-v8-no-removed-future-flags` | installed v8 Framework config | Inspect only the default-exported config. Remove exact `future.v8_*` flags removed or promoted in v8 plus `unstable_previewServerPrerendering`; move `v8_splitRouteModules` to top-level `splitRouteModules`. |
### Non-AST project and dependency diagnostics
These findings belong in React Doctor's project/supply-chain passes, not the oxlint plugin:
| Diagnostic | Version boundary | Why it is not an AST rule |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| React Router v8 runtime compatibility | Node >=22.22, React/React DOM >=19.2.7, Framework Vite >=7, ESM-only consumers | Requires package-manager, engine, module-format, and workspace resolution. |
| React Router 7.12 [CSRF](https://github.com/remix-run/react-router/security/advisories/GHSA-h5cw-625j-3rxh), [redirect XSS](https://github.com/remix-run/react-router/security/advisories/GHSA-2w69-qvjg-hvjx), and [ScrollRestoration XSS](https://github.com/remix-run/react-router/security/advisories/GHSA-8v8x-cx79-35w7) fixes | `react-router >=7.0.0 <7.12.0` for CSRF; `>=7.0.0 <=7.11.0` for both XSS advisories. The advisories also name affected Remix packages. | Existing advisory/CVE infrastructure should own semver ranges and remediation. |
| React Router 7.9.6 [external-redirect fix](https://github.com/remix-run/react-router/security/advisories/GHSA-9jcx-v3wj-wh4m) | `react-router >=6.0.0 <=6.30.1` and `>=7.0.0 <=7.9.5`; the advisory also covers `@remix-run/router <=1.23.1`. | Dependency vulnerability, not a source pattern that can be made safe reliably. |
| React Router 7.9.4 [file-session-storage fix](https://github.com/remix-run/react-router/security/advisories/GHSA-9583-h5hc-x8cw) | `@react-router/node >=7.0.0 <=7.9.3`, plus affected Remix Node/Deno packages. Correlate with `createFileSessionStorage` use. | Needs dependency and feature-use correlation; avoid a duplicate generic unsigned-cookie lint. |
| React Router 7.9.0 [meta XSS fix](https://github.com/remix-run/react-router/security/advisories/GHSA-3cgp-3xvw-98x8) | `react-router >=7.0.0 <=7.8.2`; the advisory also covers `@remix-run/react >=1.15.0 <=2.17.0`. | A safe source rewrite is not equivalent to the upstream escaping fix. |
| React Router 7.5.2 [cache](https://github.com/remix-run/react-router/security/advisories/GHSA-f46r-rw29-r322)/[pre-render](https://github.com/remix-run/react-router/security/advisories/GHSA-cpj6-fhp6-mr6j) fixes | `react-router >=7.2.0 <=7.5.1` for forced-SPA cache poisoning and `>=7.0.0 <=7.5.1` for pre-render data spoofing. | These are server-runtime package flaws, not author mistakes. |
| React Router Express 7.4.1 [host validation fix](https://github.com/remix-run/react-router/security/advisories/GHSA-4q56-crqp-v477) | `@react-router/express 7.0.0-7.4.0`; the advisory also names affected `@remix-run/express` versions. | Adapter dependency flaw, not a route source pattern. |
These are the exact affected ranges returned by the repository's public GitHub security-advisory
records on the research date. The supply-chain implementation should ingest those canonical records
instead of maintaining a second hand-written range table; the rows above are review evidence, not a
new advisory database.
## Reuse and deduplication findings
| Proposed behavior | Existing code | Decision |
| -------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Navigate render-path analysis | `tanstack-start-no-navigate-in-render` | Extract/share the hardened detector. |
| Independent loader awaits | `tanstack-start-loader-parallel-fetch` | Generalize the recognizer; do not copy the dependency analysis. |
| Thrown redirect inside catch | `nextjs-no-redirect-in-try-catch` and `tanstack-start-redirect-in-try-catch` | Share catch/rethrow analysis; add React Router redirect provenance. |
| Request-body consumption | `request-body-mass-assignment` has body-parser provenance, but a different security purpose | Reuse request-binding utilities only; do not broaden the mass-assignment rule. |
| Reachable duplicate continuation calls | existing path/reachability utilities used by state/effect rules | Reuse control-flow primitives for middleware `next`; do not invent middleware-specific traversal. |
| Session/cookie provenance | generic session-cookie security constants and scanners | Reuse names only after proving the React Router storage/session source; generic naming alone is insufficient. |
| Client-only render paths | existing SSR/browser-global and effect/event-handler recognizers | Reuse render/effect boundary analysis for `.client` imports. |
| Internal anchor syntax filters | `tanstack-start-no-anchor-element` | Reuse only after adding React Router route-target proof. |
| Fetch in effects | generic `no-fetch-in-effect` | Do not add a blanket React Router duplicate. |
| Unsafe URL redirect | `clickjacking-redirect-risk` and `url-prefilled-privileged-action` | Improve the generic security detector if needed; do not add a router-branded duplicate. |
| Route/special filenames | `is-framework-route-or-special-filename` already accepts `react-router` and `remix` runtimes | Extend its route-module coverage after mode detection. |
| Import provenance | `getImportedNameFromModule` / `isImportedFromModule` | Reuse for aliases and package variants. |
| Cross-file component tracing | existing cross-file probe/dependency utilities and Next rules | Reuse for Outlet and root-boundary project checks. |
| Fast Refresh route exports | `only-export-components` tables already know data-router creators and route exports | Keep; no new rule. |
Pre- and post-research `truffler` searches found no existing React Router implementation for the
candidate names or behaviors. Exact source searches found the Next.js/TanStack redirect detectors,
TanStack route detectors, request-body security analysis, and shared import, reachability, SSR, and
filename utilities above. The empty `truffler` results for several behavior phrases are not proof
of absence on their own; the registry and filename/source searches provide the confirming audit.
## Daytona pull-request field audit
The first pushed candidate was evaluated against 2,000 pinned open-source projects. Daytona
produced 1,989 baseline reports and 1,981 candidate reports before the shared 18-minute scan budget;
all missing records were explicit time-budget failures, not malformed output or unpinned refs. The
candidate reports contained 55 React Router diagnostics across 36 repositories.
Pinned-source review confirmed the router-factory, render-phase navigation, root error boundary,
route-object, route-lazy, and loader abort-signal findings. It also exposed three detector boundaries
that were too broad:
- Imported `.client` components rendered inside an imported `ClientOnly` render prop are already
excluded from the server graph. Six findings in `frontvibe/fluid` demonstrated this shape.
- A default-exported `.client` helper nested inside a route folder is not itself a route module. The
actual `route.tsx` in `frontvibe/fluid` dynamically imported that helper inside `ClientOnly`.
- Mutating the stable `useSearchParams()` value can still synchronize navigation through an
enclosing setter, a returned serialized URL, or a proven `useNavigate()` call. Six findings in
`apache/answer` and `Yooooomi/your_spotify` demonstrated those data-flow shapes.
The shipped detectors now abstain on those proven boundaries, and each source shape is preserved as
a regression test. The `.client` JSX rule requires an imported `client-only` render-prop boundary;
the route-suffix rule only treats direct files under `routes/` as statically proven flat route
modules; and the search-param rule follows inline iterator ownership plus returned or navigated
serialization. These exits deliberately trade recall for evidence-backed precision.
## Rejected or deferred ideas
- **Blanket `no-fetch-in-effect` for route files:** already covered generically, and client-only or
background data can be legitimate even in a route component.
- **Require `HydrateFallback` whenever `clientLoader.hydrate = true`:** official docs allow no
fallback when server-rendered and client-loaded initial data are identical. Static proof of a
mismatch is too weak for a default-on rule.
- **Require an error boundary on every data route:** boundaries intentionally protect descendant
routes. Only root coverage is universal.
- **Ban `<Navigate>` or all navigation in effects:** `<Navigate>` remains necessary in Declarative
or descendant `Routes`, and `useNavigate` explicitly supports effects. A preference for loader
redirect requires route/mode and intent evidence that a per-file rule usually lacks.
- **Ban `navigate(-1)`:** the docs caution about missing or cross-domain history entries but also
document valid modal and wizard uses. Intent is not statically knowable.
- **Always use `Form` or always use `fetcher.Form`:** the correct choice depends on whether the URL
and history should change. Markup alone rarely proves that product intent.
- **Require `prefetch="intent"` on every Link:** prefetching trades latency for bandwidth and server
load; it is not universally beneficial.
- **Require custom `shouldRevalidate` to call `defaultShouldRevalidate`:** overriding the default is
the purpose of the API and can be intentional. An unconditional `false` can still be correct for
immutable data.
- **Require status 400 for every returned validation-shaped object:** the object's semantic role
cannot be inferred reliably, and 200 responses can be deliberate.
- **Require only GET/POST on every `Form`:** React Router deliberately supports PUT/PATCH/DELETE.
Native-form compatibility is a product requirement, not a universal correctness contract.
- **Require cookie secrets on every cookie/session:** unsigned preference cookies are legitimate.
Correlating an unsigned cookie with auth-sensitive fields may be useful security research, but
a blanket rule would be noisy and the known file-session vulnerability belongs in dependency
scanning.
- **Ban response-body reads in all middleware immediately:** the docs steer server middleware to
status/header changes, but some advanced response transformations may be intentional. Keep the
narrowly proven body-consumption candidate conditional until open-source evaluation establishes
its precision.
- **Require every server middleware to call `next()`:** omitting `next` intentionally short-circuits
the chain, and React Router automatically proceeds when middleware is absent. Only diagnose the
response-return contract after `next` is actually called.
- **Ban native forms or internal anchors syntactically:** full-document navigation, no-JS behavior,
resource routes, downloads, and non-router endpoints are all valid. Only project-resolved targets
make these checks sound.
- **Flag awaited loader promises as non-streaming:** the linter cannot know which data is critical to
first render. React Router supports both awaited critical data and returned streaming promises.
- **Lint unstable RSC APIs now:** the official skill marks RSC support unstable. Wait for stable
contracts or gate an experimental ruleset explicitly.
## Suggested implementation order
1. Add React Router presence, mode, installed-version, target-major, SSR/RSC, and package-source
capabilities. Treat unknown versions conservatively.
2. Ship the syntax/scope P0 rules with small surfaces: loader request body, session mutation in a
loader, multiple middleware `next`, loader data in proven error UI, and invalid lazy properties.
3. Generalize the existing TanStack render-path/loader-waterfall and cross-framework
redirect-in-catch detectors instead of cloning them.
4. Add Framework path recognition for root, route, config, client/server-only, and server entry
modules. Then ship root error coverage, aborted `handleError`, static cookie expiry, and route
module suffix checks.
5. Build the conservative route/component index and validate it first with missing `Outlet`,
descendant `Routes` without splat, duplicate IDs, invalid absolute children, and resource links.
6. Add v8 migration diagnostics only behind an explicit target-major setting; switch them to errors
when v8 is installed. Keep runtime/dependency compatibility in the project scanner.
7. Run focused tests, fuzzing, RDE open-source evaluation, and pull-request parity for each rule
separately. Test every version gate with fixtures at the boundary minor, not just one v6/v7/v8
sample. Do not ship project-aware rules until unresolved targets produce zero reports.
The first implementation PR should stay narrow: mode/version foundation plus
`react-router-no-loader-request-body` and `react-router-no-multiple-middleware-next`. Both have
direct primary evidence, compact detectors, hard version gates, and no route-index dependency. The
route index is valuable, but coupling it to the first rule would make review and false-positive
attribution unnecessarily difficult.