Repository navigation
fix(react-router): read the table a function returns when a variable hands it to the router - #2668
Merged
Merged
Conversation
…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>
This was referenced Oct 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Medusa's admin dashboard (
packages/admin/dashboard) builds its router like this, inside a class'srender():getRouteMaplives in its own module (get-route.map.tsx) and returns a literal table of 221 routes, almost all of themlazy: () => import("../../routes/…"). React Router's route-table pass (tableRoutesinsrc/resolution/frameworks/react.ts, from #2400) followsuseRoutes(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 60navigate('/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 } frombarrels, once the routes exist. This PR makes the routes exist.Fix (
frameworks/react.ts)declaredTablereports a value initialized with a call, likegetRouteMap({ … })orawait loadRoutes(), as the function to follow (heldTable).tableNamedandexportedTablethen follow it, local or imported, to the table it returns, using the existingreturnedTable.export const routes = buildRoutes()andexport default buildRoutes()are read the same way.visibleAt). Before, the firstconst 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'sconst 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....coreRoutesand...settingsRoutes.flatMap(…)are filled by the caller, from plugins at runtime. No declaration in scope names them, so they are skipped as before.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 fewobj.method()ones read dynamic registries (router.getRouteMap(),provider.getRoutes()).Verification
Regression test.
__tests__/react-router-call-tables.test.tscovers a Medusa-shaped fixture (classrender(), importedgetRouteMap, barrels,...coreRoutes, a sync case) plus six other shapes and their negatives:5c1e55f4), 7 of its 9 tests fail.visibleAtwith "first declaration" intableNamedfails 3 tests (the scoped app, the shadowed import, and the menu negative). Doing the same inexportedTablefails 2 (the exported call's table, and the menu negative).Checks.
tsc --noEmitis clean. The 20 React / React Router / router test files pass (300 tests). Full suite with the kernel built andCODEGRAPH_KERNEL_EXPECT=1,--maxWorkers=4: 7,472 passed, 0 failed, 80 skipped, on5c1e55f4plus 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 initwith the kernel, main5c1e55f4against this branch on it. Onlyreact.jsdiffers between the two builds.packages/admin/dashboardnavigate()calls that failed, 32<Link>s)serene-ui(buildRoutes = () => [...appRoutes, ...loaderRoutes])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); itsroutes.jsxhas a comment namingcreateBrowserRouter, 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,
''andindexchildren at the parent's address, pathless layouts), and follows each lazy module's barrels by hand, honouring tsconfigpaths. It shares no code with codegraph.:idroutes whoselazyloader 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).Component: Outletkind.elementnames.navigatesedges and all 90 of Mercur's have an href that fits the route's path, segment by segment.layout:Outleton each dashboard, from route objects writtenelement: <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 syncgives 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/useRoutesnext togetRoutes,getRouteMap,createRoutes,buildRoutesandgenerateRoutes. Of the router sites whose table is a local holding a call's result:useMemo(() => fn(…));this.method().Follow-ups
<Outlet />renders nothing of its own. JSX routes already readelement={<Outlet />}this way. Route objects don't, forelement: <Outlet />or alazyloader returning{ Component: Outlet }. That gives the 81 failedlayout:Outletrefs 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.returnedTablereads the firstreturn [in a function's body, even one inside a nested callback or an earlyreturn []guard. SogenerateRoutesin devanshthakker15/BANKFORM1 (if (!currentPerm.module) return [];inside a.forEachbefore 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 readuseMemo(() => 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