Skip to content

fix(react-router): read the table a function returns when a variable hands it to the router - #2668

Merged
colbymchenry merged 2 commits into
mainfrom
claude/sleepy-chandrasekhar-6b1f1f
Oct 19, 2026
Merged

colbymchenry merged 2 commits into
mainfrom
claude/sleepy-chandrasekhar-6b1f1f

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Problem

Medusa's admin dashboard (packages/admin/dashboard) builds its router like this, inside a class's render():

const routes = getRouteMap({ settingsRoutes: this.settingsRoutes, coreRoutes: this.coreRoutes })
const router = createBrowserRouter(routes, { basename: __BASE__ || "/" })

getRouteMap lives in its own module (get-route.map.tsx) and returns a literal table of 221 routes, almost all of them lazy: () => import("../../routes/…"). React Router's route-table pass (tableRoutes in src/resolution/frameworks/react.ts, from #2400) follows useRoutes(routes) / createBrowserRouter(routes) to an imported table, routes(isLoggedIn) to the table a function returns, and [MainRoutes, LoginRoutes]. But it read a variable only when the variable held the literal itself. So on a fresh index of current main, Medusa has 0 routes, its pages have no route leading to them, and its 60 navigate('/orders/…') calls fail. Mercur's admin panel (a Medusa-based marketplace) has the same shape. Every Medusa fork I found in a GitHub code search does too.

#2436 already made all 193 of Medusa's lazy route modules resolve to their pages through their export { X as Component } from barrels, once the routes exist. This PR makes the routes exist.

Fix (frameworks/react.ts)

  • declaredTable reports a value initialized with a call, like getRouteMap({ … }) or await loadRoutes(), as the function to follow (heldTable). tableNamed and exportedTable then follow it, local or imported, to the table it returns, using the existing returnedTable. export const routes = buildRoutes() and export default buildRoutes() are read the same way.
  • Which declaration a name means is the one in scope where it is used (visibleAt). Before, the first const routes = in the file won. That was harmless while only literals were read, but following calls would have made it pick up things like a sidebar's const routes = getMenu() in another function, and that menu would have become routes. The scope comes from the graph's function, method and component nodes. A function-local declaration has no node of its own, so the declarations themselves are read from the text. A declaration inside another function is that function's own. An exported name is a top-level one.
  • Tables spread in from the function's parameters stay unread. Medusa's ...coreRoutes and ...settingsRoutes.flatMap(…) are filled by the caller, from plugins at runtime. No declaration in scope names them, so they are skipped as before.
  • Methods are not followed (const routes = this.getRoutes()). In the survey below, none of the router sites that hand over a call's result calls a method, and the few obj.method() ones read dynamic registries (router.getRouteMap(), provider.getRoutes()).

Verification

Regression test. __tests__/react-router-call-tables.test.ts covers a Medusa-shaped fixture (class render(), imported getRouteMap, barrels, ...coreRoutes, a sync case) plus six other shapes and their negatives:

  • On current main (5c1e55f4), 7 of its 9 tests fail.
  • The other 2 pass on main and guard precision. One checks the import a use names is read past another function's same-named local; the other checks a menu, or a table a function computes from data, makes no routes.
  • Mutations: replacing visibleAt with "first declaration" in tableNamed fails 3 tests (the scoped app, the shadowed import, and the menu negative). Doing the same in exportedTable fails 2 (the exported call's table, and the menu negative).

Checks. tsc --noEmit is clean. The 20 React / React Router / router test files pass (300 tests). Full suite with the kernel built and CODEGRAPH_KERNEL_EXPECT=1, --maxWorkers=4: 7,472 passed, 0 failed, 80 skipped, on 5c1e55f4 plus this branch. After syncing #2666 (437b5c32, a sync-time import retry): tsc, the 12 React Router and sync-import test files (202 tests), and a re-index of all 25 A/B repos with the synced build, compared with the pre-sync build's dumps : all 25 byte-identical.

A/B on real repos

Fresh codegraph init with the kernel, main 5c1e55f4 against this branch on it. Only react.js differs between the two builds.

repo route nodes route → page route → layout navigates removed
medusajs/medusa packages/admin/dashboard 0 → 221 +221 +613 +92 (60 navigate() calls that failed, 32 <Link>s) 0
mercurjs/admin-panel 0 → 216 +216 +564 +90 (58 + 32) 0
serenedb/serenedb serene-ui (buildRoutes = () => [...appRoutes, ...loaderRoutes]) 0 → 4 +4 — — 0
22 controls dumps byte-identical

The 22 controls are #2436's 16 (bulletproof, rr-examples, infrahub, outline, berry, mantis, matx, minimal-kit, hathor-explorer, bitser, flight-planner, proshop, next-saas, t3-turbo, takenote, plus medusa, now a target) and its 5 targets (milo-ui, article-app, folo, blog-frontend, parv-portfolio), plus mercurjs/vendor-panel (a literal RouteMap, read on main already) and Suhandsa/LaunchForge-AI- (const routes = buildRoutes(isAuth); useRoutes(routes); its routes.jsx has a comment naming createBrowserRouter, so main already reads it as a router file, and this branch finds the same table without duplicating it).

Precision, against an independent oracle. The oracle parses the route map with the TypeScript compiler, walks the table with React Router's own path rules (relative children, '' and index children at the parent's address, pathless layouts), and follows each lazy module's barrels by hand, honouring tsconfig paths. It shares no code with codegraph.

  • Medusa has all 821 edges the oracle expects (221 pages, 600 layouts) and nothing missing. 13 more layout edges come from three :id routes whose lazy loader returns { Component: Outlet, … }. The loader reader (fix(react-router): a lazy route links the page its loader picks #2480) takes those for the module's page, so the routes nested under them get the detail page as a layout. That is route-object reading predating this PR (follow-up 1).
  • Mercur's admin panel has all 775 expected edges, and 5 more of that same Component: Outlet kind.
  • serene-ui's 4 routes each link the page their element names.
  • Navigation: all 92 of Medusa's new navigates edges and all 90 of Mercur's have an href that fits the route's path, segment by segment.
  • New failed refs: 81 layout:Outlet on each dashboard, from route objects written element: <Outlet />. Main has the same refs wherever such tables were already read (87 on mercurjs/vendor-panel, 1 on rr-examples), and they link nothing (follow-up 1).

Sync. On both dashboards, holding out the table file or the file that hands it over, indexing, restoring it and running codegraph sync gives a graph byte-identical to a fresh index (4 of 4 runs). The pass runs on every sync, so this is expected. The test's sync case pins it on a fixture: edit the table, switch the router to another table, switch back.

Cost. The table pass's median over 9 runs, 2 interleaved rounds per arm: Medusa 165–169 ms → 174–176 ms (it now makes 221 routes); outline 46–47 → 48 ms and milo-ui 62–64 → 63–68 ms (nothing new). Whole-index times are within noise (Medusa 11.9 s → 12.0 s, Mercur 11.9 s → 12.1 s).

Survey. 1,143 files from GitHub code searches for createBrowserRouter / useRoutes next to getRoutes, getRouteMap, createRoutes, buildRoutes and generateRoutes. Of the router sites whose table is a local holding a call's result:

  • 70 call an imported function (69 repos, most of them Medusa and Mercur forks);
  • 30 call a local function;
  • 7 wrap it in useMemo(() => fn(…));
  • none call this.method().

Follow-ups

  1. A route that renders <Outlet /> renders nothing of its own. JSX routes already read element={<Outlet />} this way. Route objects don't, for element: <Outlet /> or a lazy loader returning { Component: Outlet }. That gives the 81 failed layout:Outlet refs per dashboard above (87 on mercur-vendor on main today), and the 13 + 5 layout edges that put a detail page around its siblings' routes.
  2. returnedTable reads the first return [ in a function's body, even one inside a nested callback or an early return [] guard. So generateRoutes in devanshthakker15/BANKFORM1 (if (!currentPerm.module) return []; inside a .forEach before the real table) reads []. This predates this PR (useRoutes(routes(isLoggedIn)) goes through it too), but this PR sends more tables through it. The same follow-up would read useMemo(() => getRoutes(user), [user]) (7 sites in the survey).

Not planned: a table chosen by a conditional (const routes = token ? authRoutes : publicRoutes, 3 sites in the survey) and methods (0 sites).

🤖 Generated with Claude Code

colbymchenry and others added 2 commits October 19, 2026 12:10
…hands it to the router

Medusa's admin dashboard builds its router in a class's `render()`:
`const routes = getRouteMap({ settingsRoutes, coreRoutes })`, then
`createBrowserRouter(routes, { basename })`. `getRouteMap`, imported from
its own module, returns the literal table. The route-table pass
(`tableRoutes` in frameworks/react.ts) read a variable only when it held
the literal itself, so none of the 221 routes existed, and the dashboard's
`navigate()` calls and `<Link>`s led nowhere.

- `declaredTable` now reports a value initialized with a call
  (`getRouteMap({ … })`, `await loadRoutes()`) as the function to follow,
  and `tableNamed` / `exportedTable` follow it to the table it returns,
  local or imported. `export const routes = buildRoutes()` and
  `export default buildRoutes()` are read the same way.
- Of several declarations of a name in a file, the one in scope at the use
  is read (`visibleAt`). Scope comes from the graph's functions; a
  function-local declaration has no node of its own, so the declarations
  are read from the text. Another function's `const routes = getMenu()` is
  that function's own and is never followed. An exported name is a
  top-level one.
- What the function's parameters spread in (Medusa's `...coreRoutes`,
  filled from plugins) stays unread: no declaration in scope names it.

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