Skip to content

fix(react-router): a JSX index route is the page at its parent's address, inside the layout around it - #2423

Merged
colbymchenry merged 4 commits into
mainfrom
claude/funny-edison-b490d4
Oct 7, 2026
Merged

colbymchenry merged 4 commits into
mainfrom
claude/funny-edison-b490d4

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

#2400 taught route objects that { index: true, element } is the page at its parent's address and that a route object around others is their layout. JSX routes still did neither:

  • <Route path="/dashboard"><Route index element={<DashboardHome/>}/></Route> gave a /dashboard route with no binding. The index child was never read.
  • <Route path="/" element={<Layout/>}><Route index element={<Home/>}/><Route path="about" …/></Route>, the classic v6 shape, bound / to Layout instead of Home. Layout's header links counted only from /, and a guard like <Route element={<RequireAuth/>}> was invisible.

Fix

In the JSX branch of scanRoutes (frameworks/react.ts):

  • Index routes. <Route index> / index={true} with no path (or path="") is the page at its parent's address. The "a child that claims the address is the page there" rule now applies to JSX too. A layout or a path-only <Route path> whose address a child claims is not a page there, so one address still has one route.
  • Layouts. A <Route element> (or v3-style component=) with <Route>s inside is their layout. Each nested route gets a layout: reference, which routeLayouts already draws on Screens. This includes a pathless or path="" guard like <Route element={<RequireAuth/>}>, which is a layout at no address of its own.
  • Path-only groups stay as today: a <Route path> with no element is a route even with nothing to render, unless a child claims its address. It is never a layout.
  • Same-file tables. A table .mapped inside a <Route element> gets that layout when the table is written in the same file. chickadeeinvest's DashboardRoutes is the example.

Where an index route's address is written down. An index route's address is its parent's. When no route around it spells that out, it is not read:

  • At the top of a component's own <Routes>, the parent is wherever another file mounts the component. crwn-clothing's Shop is mounted at shop/*, so its index route is /shop, not a second home page. A top-level index route counts as / only inside the router itself (createRoutesFromElements(…), <BrowserRouter>…).
  • Under path={paths.agents} the address is not spelled out either. path={"agents"} is, and is now read as a path.

What an element renders. The same reader picks a route's page and its layout, so two existing misreads would have spread into layouts:

  • Tags written in an attribute are skipped unless the attribute hands over a page (component, element, page). Before, <Suspense fallback={<Loader/>}><AdminPanel/></Suspense> bound 15 routes of one app to the spinner.
  • The line break Prettier writes after element={ no longer hides the element. Every multi-line element used to bind to nothing.
  • When every tag reads like a guard (AuthPage matches the Auth… guard rule), the innermost one is the page rather than the outermost.
  • A guard wrapping only <Outlet/> is the guard, and element={<Outlet/>} renders nothing of its own.
  • Version 5's <Route path="" component={NotFound}/> is still a catch-all, not a page at /.

Validation

main at 31c3328d (#2400 included) against this branch, each repo indexed fresh (init -y) and compared with scripts/dump-graph.mjs. Both arms' dumps are byte-identical to the same comparison against #2400's own commit, so the PRs merged since don't touch these repos.

repo React routes bound layout edges navigates Screens transitions dump: nodes / edges / refs (−/+)
bradtraversy/proshop-v2 20 → 19 20 → 19 0 → 30 28 → 28 31 → 74 −1/+0 · −5/+34 · −0/+0
RADeveloping/chickadeeinvest 15 → 14 11 → 10 0 → 8 7 → 7 6 → 10 −1/+0 · −1/+8 · −0/+0
BlazejBatko/react-router-v6-learning-project 13 → 13 13 → 13 0 → 30 4 → 4 4 → 21 −3/+3 · −5/+35 · −0/+0
Zen-cronic/volun-mern 24 → 24 22 → 24 0 → 105 24 → 24 32 → 110 −6/+6 · −14/+121 · −0/+0
gitdagray/react_persist_login 11 → 10 11 → 10 0 → 17 19 → 19 18 → 23 −1/+0 · −7/+23 · −0/+0
dushyant615/crwn-clothing 5 → 5 5 → 5 0 → 4 4 → 4 4 → 16 −1/+1 · −2/+6 · −0/+0
sourcelocation/cboard 10 → 10 7 → 7 0 → 15 5 → 5 6 → 18 −2/+2 · −4/+19 · −0/+0
spotify/vispana 7 → 7 6 → 7 0 → 5 1 → 1 1 → 1 −1/+1 · −0/+6 · −0/+0
Hopertz/react-router-auth-v6 3 → 3 2 → 3 0 → 3 4 → 4 4 → 7 −0/+0 · −0/+4 · −0/+0
robwittman/chushi (path={"agents"}) 2 → 6 1 → 6 0 → 5 1 → 5 1 → 5 −1/+5 · −1/+15 · −1/+0
HathorNetwork/hathor-explorer (path="" at the root) 9 → 10 9 → 10 0 26 → 28 48 → 61 −0/+1 · −0/+3 · −0/+0
remix-run/react-router react-router@6.29.0 (examples + tests) 1000 → 1010 563 → 618 44 → 212 104 → 104 122 → 163 −45/+55 · −52/+275 · −0/+13
refinedev/refine examples/ (281 apps) 1179 → 1367 641 → 874 0 → 191 68 → 89 72 → 217 −203/+391 · −8/+453 · −16/+1249
bradtraversy/proshop_mern, leerob/next-saas-starter, t3-oss/create-t3-turbo (controls) byte-identical
#2400's validation set: CleanArchitecture, Binner, RankingApp, PaydirtPickem, eShopOnContainersDDD, matx, berry, mantis, material-kit-react, bulletproof-react, react-redux-realworld, react-boilerplate, takenote byte-identical

Precision, site by site:

  • Layout edges. I checked every new layout edge with an independent tag walk: the layout's component must be in the element (or component) of a <Route> still open where the route's own tag starts, or around the .map of its table. 578 of 581 pass. vanlife's three fail because of an existing import bug, not this change: import HostVanDetail, { loader as … } from './HostVanDetail' resolves the default import to the module's loader. main already binds five of that app's pages to loader or action (/vans, /login, …). The new layout references go through the same import resolution, so they inherit it. This is the standard data-router module shape, so it is filed separately.
  • New navigations. All 27 new navigates edges name their destination at the line they come from: chushi 4, hathor-explorer 2, refine 21.
  • Navigation kept. Every base navigates edge is still there, at a route of the same name. 48 of them now reach the route that claims that address, such as proshop-v2's four navigations to /, which reach HomeScreen instead of App.
  • Removed routes. Each of the 265 removed routes is a layout or a path-only group whose address a route inside it now claims. No unclaimed route was removed.
  • Bindings. No existing binding moved to a different component, except one group: 19 element={<Outlet/>} routes in React Router's own tests no longer bind to its Outlet. That repository defines Outlet itself; in an app, Outlet is a package import that resolves to nothing either way. The other 60 binding changes are routes going from unbound to bound, all from the element reader. Most are multi-line elements; the rest are a Suspense with a fallback, or RequireAuth around the page.

Survey. 428 files came from GitHub code searches for JSX index and layout routes; 386 are not tests. Run through extract() on both arms:

  • 221 layouts or path-only groups give their address to an index or path="" child.
  • 1456 routes gain their layouts.
  • 51 routes get the page they render, among them 15 that were bound to a Suspense fallback.

I read every removed and added route, which is how the descendant-<Routes>, path={expr} and fallback cases above were found and fixed.

Screens (buildScreens): every repo above stays routed.

  • The screen at a layout's address is now its index page. For example, proshop-v2's / is HomeScreen, crwn-clothing's / is Home, and cboard's /editor/:id is Editor.
  • A layout's links are drawn from each screen inside it.
  • A guard's redirect to /login is drawn from the screens it guards. In proshop-v2, PrivateRoute and AdminRoute were unattributed origins before.

Some base transitions are drawn differently, none dropped:

  • volun-mern's navigations through usePublicOrPrivateNavigate now reach more than three screens, so Screens' existing shared-chrome rule draws them once as an origin.
  • In refine, one new navigation, <Link to="/"> in finefoods-antd's Title, hits the attribution walk's 800-node cap in that 281-app monorepo.

Sync converges. On a copy of proshop-v2, I edited its route file four times and ran codegraph sync after each edit: dropping the index route, restoring it, swapping a path='' guard layout, and restoring that. Each synced graph is byte-identical to a fresh init -y of the same files.

Tests.

  • __tests__/react-router.test.ts gains:
    • The classic v6 tree: layouts, nested layouts, a pathless guard, a path-only group, an unclaimed layout, Screens, and navigation landing on the index page.
    • proshop-v2's createRoutesFromElements with path='' guards.
    • Element-reader cases: fallback, Prettier line breaks, a page passed as a prop, an Auth… page, and rememberMe={<…/>}.
    • The parent-address rule: descendant <Routes>, path={paths.x}, path={"x"}, and an index route at the top of <BrowserRouter> and of createRoutesFromElements.
    • v5 catch-all and v3 layouts.
    • A sync test (an index route added, then removed) compared with a fresh index.
  • Two existing expectations change on purpose:
    • The nested/index boundary test now binds /dashboard to DashboardHome.
    • The same-file table under <Route element={<DashboardLayout/>}> now has its layout edges. A table imported from another file and mapped under a layout is pinned as getting none.
  • Against fix(react-router): read route tables another file hands the router #2400's react.ts, 18 of these fail. A 19th, fix(react-router): read route tables another file hands the router #2400's own two-sync table test, also failed in that run by timing out at 6.2 s under load; it passes on this branch.
  • tsc is clean, and all 72 tests in the two React Router test files pass.
  • Full suite on this shared Windows machine: 6051 passed and 32 failed, all of them in git, sync, watcher, WAL, daemon or timing files. 30 of the 32 were timeouts, and the CPU sat at 100% from other sessions.
  • Re-run serially with 120 s timeouts, 987 of those 988 tests pass. The last one is function-ref's Python-only Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #1820 case. It hits its own 60 s timeout even alone at that load, and it indexes no React file.

Known limits

  • An index route at the top of a component's own <Routes> is not read, because its address is wherever another file mounts the component. That includes an App.jsx whose <Routes> is the app's root while <BrowserRouter> sits in main.jsx. Its paths, read as before, still compose from the root. Composing descendant routes under their mount is the cross-file follow-up fix(react-router): read route tables another file hands the router #2400 already notes.
  • A table written in another file and mapped inside a <Route element> gets no layout edge. The layout's name would be looked up in the table's file. Also, the cross-file pass compares only route names, so a changed layout would not reach the table's routes on sync.
  • refine's apps take their layouts (ThemedLayout) and index pages (NavigateToResource) from refine's own packages. Those references fail, which accounts for the +1249 refs. Base already leaves the same kind of failed reference for any route whose page comes from a package.
  • A default import of a module that exports a function above export default function X() resolves to that earlier function. This existing import-resolver bug misbinds data-router pages and the layouts that use them; it is filed as a separate task.

Overlap

#2420 (open) touches the same files in these places:

  • It also adds a trap 14 to docs/design/framework-coverage.md, and edits the same React Router row and "Last verified" line.
  • It adds tests to __tests__/react-router.test.ts a few lines above this PR's same-file-table test.

Whichever lands second renumbers its trap to 15 and merges the row. The code doesn't overlap: #2420 changes how a JSX name binds in react.ts's resolve(), and this PR changes the route scanner and the element reader.

Docs

  • The React Router row and a new trap (14) in docs/design/framework-coverage.md.
  • A CHANGELOG bullet under [Unreleased] → ### Fixes.
  • A Screens bullet in docs/viewer-launch-changelog.md.

🤖 Generated with Claude Code

colbymchenry and others added 4 commits October 7, 2026 00:40
…ess, inside the layout around it

Route objects already read `{ index: true, element }` as the page at the
parent's address and a route object around others as their layout. JSX
routes did neither: `<Route path="/" element={<Layout/>}><Route index
element={<Home/>}/>…</Route>` bound `/` to Layout, the index child was never
read, and Layout's links counted only from `/`.

The JSX branch of scanRoutes now:
- reads `<Route index>` / `index={true}` (and a `path=""` leaf with an
  element) as the page at its parent's address, and lets it claim that
  address from the layout or the path-only `<Route path>` around it;
- treats a `<Route element>` with `<Route>`s inside as their layout
  (`layout:` references), a pathless or `path=""` one included, and threads
  those layouts into a table mapped inside it when the table is written in
  the same file;
- reads an index route only where its parent's address is written down: an
  enclosing route with a readable path, or the router itself
  (`createRoutesFromElements(…)`, `<BrowserRouter>`). At the top of a
  component's own `<Routes>` the address is wherever another file mounts the
  component, and under `path={paths.x}` it is not spelled out;
- reads `path={"agents"}` as a path.

Reading what an element renders: tags inside an attribute are no longer the
page unless the attribute hands one over (`component`, `element`, `page`),
so `<Suspense fallback={<Loader/>}>` no longer binds the spinner; the line
break Prettier writes after `element={` no longer hides the element; a guard
that wraps only `<Outlet/>` is the guard; and `element={<Outlet/>}` renders
nothing of its own.

Validated against the merged #2400 build: 16 control repos byte-identical,
578 of 581 new layout edges name a component of an enclosing <Route> (3
inherit a pre-existing default-import resolution bug), 27 of 27 new
navigations precise, no navigation lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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.

1 participant