From 654a853f5a65c57718b5077bbeaac55e70abfd72 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 17:22:57 +0000 Subject: [PATCH] fix(react-router): read the table a function returns when a variable hands it to the router (#2668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``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 --- CHANGELOG.md | 1 + __tests__/react-router-call-tables.test.ts | 427 +++++++++++++++++++++ docs/design/framework-coverage.md | 16 +- src/resolution/frameworks/react.ts | 94 ++++- 4 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 __tests__/react-router-call-tables.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0a6fe8d..090423ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: `. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading. - A React Router route, or a page loaded with `lazy(() => import(…))`, now links to its page when the folder it loads passes the page on through an import of its own, like `import { UserLayout } from './UserLayout'; export { UserLayout }` or `import Login from './Login'; export default Login`. Before, the folder's `index.ts` was searched for a page declared in it, which it doesn't have, so the route linked nothing. A default export that wraps the page in a call, like `export default withAuth(Page)`, still links neither the wrapper nor a guess. Re-index React projects after upgrading. - `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it. +- React Router routes are now indexed when the router gets its table from a function through a variable, as in Medusa's admin dashboard, which writes `const routes = getRouteMap({ … })` and then `createBrowserRouter(routes)`. The same goes for `export const routes = buildRoutes()` or `export default buildRoutes()` imported into `useRoutes(routes)`, and for `const routes = await loadRoutes()`. Before, such a variable was read only when it held the table itself, so these apps had no routes: their pages had no route leading to them, and their `navigate('/orders/…')` calls and `` links led nowhere. Routes the function is handed by its caller, like the plugin routes Medusa spreads in, are still not read, and when two functions in one file each keep a `routes` variable, the router reads its own and never the other's, which may be a menu. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. - In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `

Don't have an account? `, still links as before. Re-index React projects after upgrading. diff --git a/__tests__/react-router-call-tables.test.ts b/__tests__/react-router-call-tables.test.ts new file mode 100644 index 000000000..39e2716f9 --- /dev/null +++ b/__tests__/react-router-call-tables.test.ts @@ -0,0 +1,427 @@ +/** + * A route table handed to React Router through a local that holds a call's + * result: Medusa's admin dashboard writes + * + * const routes = getRouteMap({ settingsRoutes: this.settingsRoutes, coreRoutes: this.coreRoutes }) + * const router = createBrowserRouter(routes, { basename }) + * + * inside a class's `render()`, and `getRouteMap`, imported from its own + * module, returns the literal table. The table is what the function returns. + * Before, the local was read only for a literal, so none of Medusa's routes + * existed. The tables spread in from the function's parameters (`...coreRoutes`) + * are the caller's to fill (Medusa's come from plugins), so they stay unread. + * + * Which `routes` a use reads is the one in scope there: another function's + * `const routes = getMenu()` is that function's own, and a menu is no table. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +function writeFiles(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } +} + +async function indexProject(files: Record): Promise<{ root: string; cg: CodeGraph }> { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-call-tables-')); + writeFiles(root, files); + return { root, cg: await CodeGraph.init(root, { index: true }) }; +} + +/** `path -> Component` for what each route renders, `path ~> Layout` for each layout around it, under `dir`. */ +function routeBindings(cg: CodeGraph, dir: string): string[] { + return cg.getNodesByKind('route').filter((r) => r.filePath.startsWith(dir)).flatMap((r) => cg.getOutgoingEdges(r.id) + .filter((e) => e.kind === 'references') + .map((e) => `${r.name} ${(e.metadata as Record | undefined)?.layout ? '~>' : '->'} ${cg.getNode(e.target)?.name}`)) + .sort(); +} + +const routeNames = (cg: CodeGraph, dir: string): string[] => + cg.getNodesByKind('route').filter((r) => r.filePath.startsWith(dir)).map((r) => r.name).sort(); + +/** A component that renders the routes inside it. */ +const layout = (name: string): string => + `import { Outlet } from 'react-router-dom';\n\nexport const ${name} = () => {\n return ;\n};\n`; + +/** A route module the way Medusa writes one: a barrel that hands React Router its page as `Component`. */ +const routeModule = (dir: string, name: string, file: string): Record => ({ + [`${dir}/index.ts`]: `export { ${name} as Component } from './${file}';\n`, + [`${dir}/${file}.tsx`]: `export const ${name} = () => {\n return

${name}
;\n};\n`, +}); + +const MEDUSA_APP = `import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router-dom'; +import { getRouteMap } from './routes/get-route.map'; + +export class DashboardApp { + private coreRoutes: RouteObject[]; + private settingsRoutes: RouteObject[]; + + constructor({ plugins }: { plugins: { routes: RouteObject[] }[] }) { + this.coreRoutes = plugins.flatMap((p) => p.routes); + this.settingsRoutes = []; + } + + render() { + const routes = getRouteMap({ + settingsRoutes: this.settingsRoutes, + coreRoutes: this.coreRoutes, + }); + + const router = createBrowserRouter(routes, { + basename: '/app', + }); + + return ; + } +} +`; + +const routeMap = (extra = ''): string => `import { RouteObject } from 'react-router-dom'; +import { ProtectedRoute } from '../../components/protected-route'; +import { MainLayout } from '../../components/main-layout'; +import { SettingsLayout } from '../../components/settings-layout'; +import { PublicLayout } from '../../components/public-layout'; + +export function getRouteMap({ + settingsRoutes, + coreRoutes, +}: { + settingsRoutes: RouteObject[] + coreRoutes: RouteObject[] +}) { + return [ + { + element: , + children: [ + { + element: , + children: [ + { + path: '/', + lazy: () => import('../../routes/home'), + }, + { + path: '/products', + children: [ + { + path: '', + lazy: () => import('../../routes/products/product-list'), + children: [ + { + path: 'create', + lazy: () => + import('../../routes/products/product-create'), + }, + ], + }, + ], + },${extra} + ...coreRoutes, + ], + }, + { + element: , + children: [ + { + path: '/settings', + lazy: () => import('../../routes/settings'), + }, + ...settingsRoutes.flatMap((r) => r?.children || []), + ], + }, + ], + }, + { + element: , + children: [ + { + path: '/login', + lazy: () => import('../../routes/login'), + }, + ], + }, + ]; +} +`; + +const ORDERS = ` + { + path: '/orders', + lazy: () => import('../../routes/orders'), + },`; + +const MEDUSA: Record = { + 'admin/package.json': JSON.stringify({ name: 'dashboard', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'admin/src/dashboard-app/dashboard-app.tsx': MEDUSA_APP, + 'admin/src/dashboard-app/routes/get-route.map.tsx': routeMap(), + 'admin/src/components/protected-route.tsx': layout('ProtectedRoute'), + 'admin/src/components/main-layout.tsx': layout('MainLayout'), + 'admin/src/components/settings-layout.tsx': layout('SettingsLayout'), + 'admin/src/components/public-layout.tsx': layout('PublicLayout'), + ...routeModule('admin/src/routes/home', 'Home', 'home'), + ...routeModule('admin/src/routes/products/product-list', 'ProductList', 'product-list'), + ...routeModule('admin/src/routes/products/product-create', 'ProductCreate', 'product-create'), + ...routeModule('admin/src/routes/settings', 'Settings', 'settings'), + ...routeModule('admin/src/routes/login', 'Login', 'login'), + ...routeModule('admin/src/routes/orders', 'Orders', 'orders'), +}; + +const MEDUSA_BINDINGS = [ + '/ -> Home', + '/ ~> MainLayout', + '/ ~> ProtectedRoute', + '/login -> Login', + '/login ~> PublicLayout', + // The `''` route under `/products` is the page there, and the layout its + // `create` child renders inside. + '/products -> ProductList', + '/products ~> MainLayout', + '/products ~> ProtectedRoute', + '/products/create -> ProductCreate', + '/products/create ~> MainLayout', + '/products/create ~> ProductList', + '/products/create ~> ProtectedRoute', + '/settings -> Settings', + '/settings ~> ProtectedRoute', + '/settings ~> SettingsLayout', +]; + +/** + * Apps whose routers are handed a call's result in other ways, and the + * shapes around them that are no route table. + */ +const SHAPES: Record = { + 'package.json': JSON.stringify({ name: 'shapes', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/icons.jsx': `export function HelpIcon() {\n return ;\n}\nexport function BillingIcon() {\n return ;\n}\n`, + 'src/pages.jsx': ['Home', 'Account', 'Dashboard', 'Landing', 'TodoList', 'Reports'] + .map((p) => `export function ${p}() {\n return
${p}
;\n}\n`).join(''), + // A menu: a `{ path, element }` list that nothing hands the router. + 'src/menu.jsx': `import { BillingIcon, HelpIcon } from './icons'; + +export function getMenu() { + return [ + { path: '/help', element: }, + { path: '/billing', element: }, + ]; +} +`, + + // Two functions with a `routes` each: the router is handed App's own. + 'src/scoped/routes.jsx': `import { Account, Home } from '../pages'; + +export const getRoutes = (user) => [ + { path: '/', element: }, + { path: '/account', element: }, +]; +`, + 'src/scoped/App.jsx': `import { Link, useRoutes } from 'react-router-dom'; +import { getMenu } from '../menu'; +import { getRoutes } from './routes'; + +function Sidebar() { + const routes = getMenu(); + return ; +} + +export default function App({ user }) { + const routes = getRoutes(user); + const element = useRoutes(routes); + return
{element}
; +} +`, + + // The router is handed the import; another function's local of that name is its own. + 'src/shadow/routes.jsx': `import { Landing } from '../pages'; + +const routes = [{ path: '/landing', element: }]; + +export default routes; +`, + 'src/shadow/App.jsx': `import { useRoutes } from 'react-router-dom'; +import routes from './routes'; +import { getMenu } from '../menu'; + +function Sidebar() { + const routes = getMenu(); + return
    {routes.map((r) =>
  • {r.path}
  • )}
; +} + +export default function App() { + return useRoutes(routes); +} +`, + + // An exported table that is a call's result, past a function's own local of that name. + 'src/exported/routes.jsx': `import { Dashboard } from '../pages'; +import { getMenu } from '../menu'; + +export function menuSize() { + const routes = getMenu(); + return routes.length; +} + +const makeRoutes = () => [{ path: '/dashboard', element: }]; + +export const routes = makeRoutes(); +`, + 'src/exported/App.jsx': `import { useRoutes } from 'react-router-dom'; +import { routes } from './routes'; + +export default function App() { + return useRoutes(routes); +} +`, + + // A default export that is a call's result. + 'src/default/build.jsx': `import { Reports } from '../pages'; + +export function buildRoutes() { + return [{ path: '/reports', element: }]; +} +`, + 'src/default/routes.js': `import { buildRoutes } from './build'; + +export default buildRoutes(); +`, + 'src/default/App.jsx': `import { useRoutes } from 'react-router-dom'; +import routes from './routes'; + +export default function App() { + return useRoutes(routes); +} +`, + + // A table an async function returns, awaited before the router gets it. + 'src/awaited/load.jsx': `import { Account } from '../pages'; + +export async function loadRoutes() { + return [{ path: '/me', element: }]; +} +`, + 'src/awaited/boot.jsx': `import { createBrowserRouter } from 'react-router-dom'; +import { loadRoutes } from './load'; + +export async function boot() { + const routes = await loadRoutes(); + return createBrowserRouter(routes); +} +`, + + // A router file that builds its table in a function of its own: its route + // objects were read when it was extracted; the table it names is new here. + 'src/features/todo/routes.jsx': `import { TodoList } from '../../pages'; + +export const todoRoutes = [{ path: '/todos', element: }]; +`, + 'src/local/main.jsx': `import { createBrowserRouter } from 'react-router-dom'; +import { todoRoutes } from '../features/todo/routes'; +import { Home } from '../pages'; + +function buildRoutes() { + return [{ path: '/home', element: }, ...todoRoutes]; +} + +const routes = buildRoutes(); + +export const router = createBrowserRouter(routes); +`, + + // A function that makes its table from data returns no literal to read. + 'src/computed/App.jsx': `import { useRoutes } from 'react-router-dom'; +import { pageList } from './pages'; + +function buildRoutes(pages) { + return pages.map((p) => ({ path: p.path, element: p.element })); +} + +export default function App() { + const routes = buildRoutes(pageList); + return useRoutes(routes); +} +`, + 'src/computed/pages.jsx': `import { HelpIcon } from '../icons'; + +export const pageList = [{ path: '/computed', element: }]; +`, +}; + +describe("react-router: a table a function returns, handed over through a local (Medusa's admin dashboard)", () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject(MEDUSA)); + }, 60_000); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads the table getRouteMap returns, each lazy route bound to the page its module hands React Router', () => { + expect(routeBindings(cg, 'admin/')).toEqual(MEDUSA_BINDINGS); + }); + + it('keeps the routes in the file that writes them', () => { + const home = cg.getNodesByKind('route').find((r) => r.name === '/')!; + expect(home.filePath).toBe('admin/src/dashboard-app/routes/get-route.map.tsx'); + expect(home.startLine).toBe(22); + }); + + it('follows an edit to the table, and drops the routes when the router is handed something else', async () => { + writeFiles(root, { 'admin/src/dashboard-app/routes/get-route.map.tsx': routeMap(ORDERS) }); + await cg.sync(); + expect(routeBindings(cg, 'admin/')).toEqual( + [...MEDUSA_BINDINGS, '/orders -> Orders', '/orders ~> MainLayout', '/orders ~> ProtectedRoute'].sort()); + + writeFiles(root, { 'admin/src/dashboard-app/dashboard-app.tsx': MEDUSA_APP.replace(/getRouteMap\(\{[\s\S]*?\}\);/, '[];') }); + await cg.sync(); + expect(routeNames(cg, 'admin/')).toEqual([]); + + writeFiles(root, { 'admin/src/dashboard-app/dashboard-app.tsx': MEDUSA_APP }); + await cg.sync(); + expect(routeNames(cg, 'admin/')).toEqual(['/', '/login', '/orders', '/products', '/products/create', '/settings']); + }, 60_000); +}); + +describe('react-router: the other ways a call hands the router its table, and what stays no table', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject(SHAPES)); + }, 60_000); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("reads the table of the `routes` in the router's own scope, never another function's menu", () => { + expect(routeBindings(cg, 'src/scoped/')).toEqual(['/ -> Home', '/account -> Account']); + }); + + it('reads the imported table a use names, past a same-named local in another function', () => { + expect(routeBindings(cg, 'src/shadow/')).toEqual(['/landing -> Landing']); + }); + + it('reads an exported table that a call makes, as a named or a default export', () => { + expect(routeBindings(cg, 'src/exported/')).toEqual(['/dashboard -> Dashboard']); + expect(routeBindings(cg, 'src/default/')).toEqual(['/reports -> Reports']); + }); + + it('reads the table an awaited call returns', () => { + expect(routeBindings(cg, 'src/awaited/')).toEqual(['/me -> Account']); + }); + + it('reads the tables a router file names inside the table its own function returns', () => { + expect(routeBindings(cg, 'src/features/')).toEqual(['/todos -> TodoList']); + expect(routeBindings(cg, 'src/local/')).toEqual(['/home -> Home']); + }); + + it('makes no route of a menu, nor of a table a function computes from data', () => { + const all = cg.getNodesByKind('route').map((r) => r.name); + for (const p of ['/help', '/billing', '/computed']) expect(all).not.toContain(p); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 82a0ed231..18ae63f9a 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -1,6 +1,6 @@ # Framework & language coverage — what is done, what is left -**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-14) against the build at that date. Re-verify with the +**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-19) against the build at that date. Re-verify with the queries in [Checking this file is still true](#checking-this-file-is-still-true) before trusting a row; this is a snapshot, not a live view. @@ -38,7 +38,7 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels, but its routes are not extracted: `getRouteMap()` returns the table), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical | +| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). A table a function returns, handed over through a variable (`const routes = getRouteMap({ … })` in a class's `render()`, then `createBrowserRouter(routes)`; trap 13): Medusa's admin dashboard (0 → 221 routes, each bound to its page, 613 layout edges, 92 navigations newly linked: 60 `navigate()` calls and 32 ``s; all 821 edges an AST oracle on the TypeScript compiler expects, the other 13 from three `:id` loaders that return `Component: Outlet`, read as their module's page), Mercur's admin panel (0 → 216; 775 of 775, 5 the same), serenedb's serene-ui (`buildRoutes = () => [...appRoutes, ...loaderRoutes]`: 0 → 4), 22 control repos byte-identical, and a late-file sync equal to a fresh index on both dashboards. JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | @@ -282,7 +282,17 @@ Each of these cost real debugging time; they are not hypothetical. it shares with every router: a table rendered by a component that is itself mounted under another file's `` reads as if at the root. A sync that changes such a route resolves the calls it can - answer again (16), as for any other route. + answer again (16), as for any other route. A variable that holds a call's + result stands for the table the function returns: Medusa's `const routes = + getRouteMap({ … })` inside a class's `render()`, `export const routes = + buildRoutes()`, `export default buildRoutes()`, `await loadRoutes()`. Which + declaration a name means is the one in scope where it is used, decided by + the graph's functions: another function's `const routes = getMenu()` is + that function's own, and an exported name is a top-level one. A + function-local declaration has no node, so the declarations are read from + the text and only the scope comes from the graph. What the function's + parameters bring in (Medusa's `...coreRoutes`, from plugins) is the + caller's to fill and stays unread. 14. **An index route is at its parent's address, so it needs that address written down.** `} />` inside `}>` is the page at `/`, and Layout is the diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 99983ec1b..1c82e378c 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -11,7 +11,7 @@ import { dependsOn } from './package-deps'; import { resolveImportPath } from '../import-resolver'; import { defaultExportBinding, extractLocalExportAliases } from '../alias-binding'; import { stripCommentsForRegex } from '../strip-comments'; -import { makeLineAt } from '../synth-utils'; +import { FN_KINDS, makeLineAt } from '../synth-utils'; import { isTestPath } from '../../search/query-utils'; import { matchBracket, skipString } from './object-literal'; @@ -1989,13 +1989,18 @@ function scriptLanguage(filePath: string): Language { return /\.tsx$/.test(filePath) ? 'tsx' : /\.[cm]?ts$/.test(filePath) ? 'typescript' : /\.jsx$/.test(filePath) ? 'jsx' : 'javascript'; } -/** The literal a table name stands for in `file`: declared there, or imported. */ +/** + * The literal a table name stands for in `file`: declared there, or + * imported. A value that holds a call's result — Medusa's `const routes = + * getRouteMap({ … })` — stands for the table the function returns. + */ function tableNamed(named: TableName, file: string, context: ResolutionContext, depth = 0): { file: string; at: number } | null { const source = context.readFile(file); if (!source) return null; const safe = stripCommentsForRegex(source, 'typescript'); - const local = declaredTable(safe, named.name, named.call); - if (local !== null) return { file, at: local }; + const local = declaredTable(safe, named.name, named.call, visibleAt(named.at, safe, file, context)); + if (typeof local === 'number') return { file, at: local }; + if (local) return depth > 3 ? null : tableNamed(local, file, context, depth + 1); const language = scriptLanguage(file); const mapping = context.getImportMappings(file, language).find((m) => m.localName === named.name); if (!mapping || mapping.isNamespace || depth > 3) return null; @@ -2014,8 +2019,10 @@ function exportedTable(file: string, exported: string, call: boolean, context: R const m = /\bexport\s+default\s+/.exec(safe); if (m) { const after = m.index + m[0].length; - const inline = call ? returnedTable(safe, after) : safe[after] === '[' || safe[after] === '{' ? after : null; - if (inline !== null) return { file, at: inline }; + const inline = call ? returnedTable(safe, after) : heldTable(safe, after); + if (typeof inline === 'number') return { file, at: inline }; + // `export default buildRoutes()` + if (inline) return depth > 3 ? null : tableNamed(inline, file, context, depth + 1); local = /^([A-Za-z_$][\w$]*)\s*(?:;|\n|$)/.exec(safe.slice(after))?.[1] ?? ''; } else local = /\bexport\s*\{[^}]*?\b([A-Za-z_$][\w$]*)\s+as\s+default\b/.exec(safe)?.[1] ?? ''; } else { @@ -2023,8 +2030,10 @@ function exportedTable(file: string, exported: string, call: boolean, context: R const alias = new RegExp(`\\bexport\\s*\\{[^}]*?\\b([A-Za-z_$][\\w$]*)\\s+as\\s+${exported.replace(/\$/g, '\\$')}\\b`).exec(safe); if (alias) local = alias[1]!; } - const at = local ? declaredTable(safe, local, call) : null; - if (at !== null) return { file, at }; + // What a module exports is declared at its top level. + const at = local ? declaredTable(safe, local, call, visibleAt(undefined, safe, file, context)) : null; + if (typeof at === 'number') return { file, at }; + if (at) return depth > 3 ? null : tableNamed(at, file, context, depth + 1); if (depth > 3) return null; // `export { routes } from './routes'`, `export { default as routes } from './routes'`, `export * from './routes'`. const language = scriptLanguage(file); @@ -2038,20 +2047,81 @@ function exportedTable(file: string, exported: string, call: boolean, context: R return null; } -/** Where `name`'s table literal opens, for `const name = [ … ]` (or a function returning one when `call`). */ -function declaredTable(safe: string, name: string, call: boolean): number | null { +/** + * Where `name`'s table literal opens, for `const name = [ … ]` (or a function + * returning one when `call`) — or, for `const name = getRouteMap({ … })`, the + * function whose returned table the value holds. Of several declarations of + * the name, the one `visible` picks. + */ +function declaredTable( + safe: string, + name: string, + call: boolean, + visible: (keywords: number[]) => number | undefined +): number | TableName | null { const id = name.replace(/\$/g, '\\$'); - const decl = new RegExp(`(?:^|[^\\w$.])(?:const|let|var)\\s+${id}\\s*(?::[^=;]*)?=\\s*`).exec(safe); + const decls = [...safe.matchAll(new RegExp(`(?:^|[^\\w$.])((?:const|let|var)\\s+${id}\\s*(?::[^=;]*)?=\\s*)`, 'g'))]; + const chosen = decls.length > 0 ? visible(decls.map((d) => d.index + d[0].length - d[1]!.length)) : undefined; + const decl = chosen === undefined ? undefined : decls[chosen]; if (decl) { const after = decl.index + decl[0].length; if (call) return returnedTable(safe, after); - return safe[after] === '[' || safe[after] === '{' ? after : null; + return heldTable(safe, after); } if (!call) return null; const fn = new RegExp(`(?:^|[^\\w$.])function\\s+${id}\\s*\\(`).exec(safe); return fn ? returnedTable(safe, fn.index + fn[0].indexOf('function')) : null; } +/** + * The table a value written at `at` gives: the literal there, or the + * function a call there names — `getRouteMap({ … })`, `await loadRoutes()` — + * whose returned table it is. + */ +function heldTable(safe: string, at: number): number | TableName | null { + if (safe[at] === '[' || safe[at] === '{') return at; + const call = /(await\s+)?([A-Za-z_$][\w$]*)\s*\(/y; + call.lastIndex = at; + const m = call.exec(safe); + return m ? { name: m[2]!, call: true, at: at + (m[1]?.length ?? 0) } : null; +} + +/** + * Picks, of a name's declarations in `file` (where each keyword sits), the + * one in scope at `use`: in the innermost function around the use that + * declares the name, the last declaration before the use, else the first + * after it; else one at the module's top level. A declaration inside another + * function is that function's own. With no use, it picks one at the top + * level, which is what a module exports. The functions are the graph's — a + * component, a method, an arrow function held in a `const` — so the blocks + * inside one function are not told apart. + */ +function visibleAt(use: number | undefined, safe: string, file: string, context: ResolutionContext): (keywords: number[]) => number | undefined { + return (keywords) => { + const lineStarts = [0]; + for (let i = safe.indexOf('\n'); i !== -1; i = safe.indexOf('\n', i + 1)) lineStarts.push(i + 1); + const offset = (line: number, column: number): number => (lineStarts[line - 1] ?? safe.length) + column; + const fns = context.getNodesInFile(file) + .filter((n) => FN_KINDS.has(n.kind)) + .map((n) => ({ start: offset(n.startLine, n.startColumn), end: offset(n.endLine, n.endColumn) })); + // The function a declaration's own value is (`const routes = (user) => + // [ … ]`) starts after the keyword, so it is not around it. + const around = (at: number) => fns.filter((f) => f.start < at && f.end > at); + const scopes = new Set(use === undefined ? [] : around(use)); + let best: number | undefined; + let depth = -1; + keywords.forEach((at, i) => { + const encl = around(at); + if (!encl.every((f) => scopes.has(f))) return; + if (encl.length > depth || (encl.length === depth && use !== undefined && at < use)) { + best = i; + depth = encl.length; + } + }); + return best; + }; +} + /** The table a function at `at` returns: `(user) => [ … ]`, `(user) => ([ … ])`, `function routes(user) { return [ … ]; }`. */ function returnedTable(safe: string, at: number): number | null { const head = /^(?:async\s+)?(?:function\b[^(]*\([^)]*\)|\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^={]*)?(=>)?\s*/.exec(safe.slice(at));