From dd7064698c1b0c0e2801c8f4da076ff6810818d9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 02:27:44 +0000 Subject: [PATCH] fix(react-router): read route tables another file hands the router (#2400) A route table kept in a file of its own produced no routes: the ASP.NET Core React template's AppRoutes array, which App.js maps into , a routes array imported into useRoutes(routes) or createBrowserRouter(routes), and codedthemes' one-object-per-file [MainRoutes, LoginRoutes]. Route objects were read only from a file that itself names a data router, so these apps had no routes and their navigate()/ navigation reached nothing. The table's own file cannot tell it from a menu's { path, element } list, so the evidence is the file that hands it over: a useRoutes / create*Router argument, or a .map whose callback renders a from the item's own fields ({...route}, {...rest}, path={route.path}). A new FrameworkResolver.crossFileNodes hook keeps those routes, reconciled in runPostExtract on every index and sync (removal-only syncs included): new ones are inserted with pending references, ones no longer wanted are removed with their incoming resolution edges resurrected, unchanged ones keep their edges. The React pass starts from the files that import React Router, follows the import to the table and the tables it names (...ApiAuthorizationRoutes, children: x), each under its mount, and owns its routes by id (route:::table:). Route objects (tables and data routers alike): an index: true route is the page at its parent's address; a route object around others is their layout (a references edge marked layout: true, as Angular's), and is not a page of its own where a child claims its address. A route renders past a *Wrapper or *Route guard, and through a same-file lazy(() => import(...)) to the module it loads, never a same-named component in another app. useRoutes joins the file-level gate. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/react-router-nested-lazy.test.ts | 12 +- __tests__/react-router.test.ts | 573 ++++++++++++++++++++- docs/design/framework-coverage.md | 24 +- docs/viewer-launch-changelog.md | 2 + src/extraction/index.ts | 34 +- src/index.ts | 20 +- src/resolution/frameworks/react-router.ts | 17 +- src/resolution/frameworks/react.ts | 565 ++++++++++++++++++-- src/resolution/index.ts | 56 +- src/resolution/resurrect-ref.ts | 38 ++ src/resolution/types.ts | 25 + 12 files changed, 1270 insertions(+), 97 deletions(-) create mode 100644 src/resolution/resurrect-ref.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4169a4227..71dd32e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. +- React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/react-router-nested-lazy.test.ts b/__tests__/react-router-nested-lazy.test.ts index e47dccdfc..da5d59414 100644 --- a/__tests__/react-router-nested-lazy.test.ts +++ b/__tests__/react-router-nested-lazy.test.ts @@ -75,11 +75,14 @@ afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); }); -const renders = (routeName: string): string[] => { +const bindings = (routeName: string, layout: boolean): string[] => { const route = cg.getNodesByKind('route').find((r) => r.name === routeName); if (!route) return []; - return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name); + return cg.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references' && Boolean((e.metadata as Record | undefined)?.layout) === layout) + .map((e) => cg.getNode(e.target)!.name); }; +const renders = (routeName: string): string[] => bindings(routeName, false); describe('nested, lazy and constant React Router routes', () => { it('compose their paths and render their modules', () => { @@ -90,6 +93,11 @@ describe('nested, lazy and constant React Router routes', () => { expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']); }); + it('a child route renders inside its parent’s component, the layout around it', () => { + expect(bindings('/app/discussions', true)).toEqual(['AppRoot']); + expect(bindings('/', true)).toEqual([]); + }); + it('resolves an app’s own tsconfig alias', () => { const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id); const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index fa5cd56e1..1ae76ff78 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -574,8 +574,9 @@ describe('react-router: route declaration boundaries (#1348)', () => { ]); `, extension); expect(result).toEqual({ - paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'], - bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], + // `{ index: true, Component: DataIndex }` is the page at its parent's address. + paths: ['/', '/data', '/data/prefs', '/long', '/quoted', '/sibling'], + bindings: ['/->DataSettings', '/data->DataIndex', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], }); }); @@ -692,3 +693,571 @@ export default function FeaturePage() { return

Features

; } expect(navsFrom('PrivateRoute')).toEqual(['redirect /']); }); }); + +// ============================================================================= +// Route tables another file hands the router +// ============================================================================= + +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-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. */ +function routeBindings(cg: CodeGraph): string[] { + return cg.getNodesByKind('route').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(); +} + +/** `Source -> /path` for every navigation into a route. */ +function navigations(cg: CodeGraph): string[] { + return cg.getNodesByKind('route').flatMap((r) => cg.getIncomingEdges(r.id) + .filter((e) => e.kind === 'navigates') + .map((e) => `${cg.getNode(e.source)?.name} -> ${r.name}`)) + .sort(); +} + +const routeNames = (cg: CodeGraph): string[] => cg.getNodesByKind('route').map((r) => r.name).sort(); + +const component = (name: string): string => `export function ${name}() {\n return
${name}
;\n}\n`; + +/** jasontaylordev/CleanArchitecture's ClientApp-React and the ASP.NET Core React template it comes from. */ +const ASPNET_TEMPLATE: Record = { + 'src/Web/ClientApp/package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/Web/ClientApp/src/AppRoutes.js': `import ApiAuthorzationRoutes from './components/api-authorization/ApiAuthorizationRoutes'; +import { Counter } from "./components/Counter"; +import { FetchData } from "./components/FetchData"; +import { Home } from "./components/Home"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import AdminWrapper from "./components/AdminWrapper"; +import { Users } from "./components/Users"; + +const AppRoutes = [ + { + index: true, + element: + }, + { + path: '/counter', + element: + }, + { + path: '/fetch-data', + requireAuth: true, + element: + }, + { + path: 'admin/users', + element: + }, + ...ApiAuthorzationRoutes +]; + +export default AppRoutes; +`, + 'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationRoutes.js': `import { Profile } from './Profile'; +import { Login } from './Login'; +import { ApplicationPaths } from './ApiAuthorizationConstants'; + +const ApiAuthorizationRoutes = [ + { + path: '/authentication/profile', + element: + }, + { + path: ApplicationPaths.Login, + element: loginAction('login') + } +]; + +function loginAction(name) { + return ; +} + +export default ApiAuthorizationRoutes; +`, + 'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationConstants.js': + "const prefix = '/authentication';\nexport const ApplicationPaths = {\n Login: `${prefix}/login`,\n};\n", + 'src/Web/ClientApp/src/App.js': `import React, { Component } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import AppRoutes from './AppRoutes'; +import AuthorizeRoute from './components/api-authorization/AuthorizeRoute'; +import { Layout } from './components/Layout'; + +export default class App extends Component { + static displayName = App.name; + + render() { + return ( + + + {AppRoutes.map((route, index) => { + const { element, requireAuth, ...rest } = route; + return : element} />; + })} + + + ); + } +} +`, + 'src/Web/ClientApp/src/components/NavMenu.js': `import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { menu } from '../menu'; + +export function NavMenu() { + const navigate = useNavigate(); + const showProfile = () => { + navigate('/authentication/profile'); + }; + return ( + + ); +} +`, + // `{ path, element }` lists that are not route tables: a menu rendered as + // links, a list nothing renders, and a table mapped into a path it does not + // hold (`layout + path`). + 'src/Web/ClientApp/src/menu.js': `import { HelpIcon, SettingsIcon } from './icons'; +export const menu = [ + { path: '/help', element: }, + { path: '/settings', element: }, +]; +`, + 'src/Web/ClientApp/src/breadcrumbs.js': `import { Crumb } from './icons'; +export const crumbs = [{ path: '/history', element: }]; +`, + 'src/Web/ClientApp/src/dashboard.js': `import { Route, Routes } from 'react-router-dom'; +import { Stats } from './icons'; +const dashboardRoutes = [{ path: '/stats', layout: '/admin', element: }]; +export function Dashboard() { + return {dashboardRoutes.map((r) => )}; +} +`, + 'src/Web/ClientApp/src/icons.js': component('HelpIcon') + component('SettingsIcon') + component('Crumb') + component('Stats'), + 'src/Web/ClientApp/src/components/Home.js': component('Home'), + 'src/Web/ClientApp/src/components/Counter.js': component('Counter'), + 'src/Web/ClientApp/src/components/FetchData.js': component('FetchData'), + 'src/Web/ClientApp/src/components/ProtectedRoute.js': 'export function ProtectedRoute({ children }) {\n return children;\n}\n', + 'src/Web/ClientApp/src/components/AdminWrapper.js': 'export default function AdminWrapper({ children }) {\n return children;\n}\n', + 'src/Web/ClientApp/src/components/Users.js': component('Users'), + 'src/Web/ClientApp/src/components/Layout.js': + "import { NavMenu } from './NavMenu';\nexport function Layout({ children }) {\n return
{children}
;\n}\n", + 'src/Web/ClientApp/src/components/api-authorization/Profile.js': component('Profile'), + 'src/Web/ClientApp/src/components/api-authorization/Login.js': component('Login'), + 'src/Web/ClientApp/src/components/api-authorization/AuthorizeRoute.js': + 'export default function AuthorizeRoute({ element }) {\n return element;\n}\n', +}; + +describe('react-router: a route table another file maps into (the ASP.NET Core React template)', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject(ASPNET_TEMPLATE)); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads the table App.js spreads into , and the table it spreads in turn', () => { + expect(routeBindings(cg)).toEqual([ + // `index: true` at the top of the table is the app's `/`. + '/ -> Home', + // `` and + // `` render what they wrap. + '/admin/users -> Users', + '/authentication/profile -> Profile', + '/counter -> Counter', + '/fetch-data -> FetchData', + ]); + // An entry that renders a call's result names no component, so it is no route. + expect(routeNames(cg)).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']); + }); + + it('keeps each route at the line of its own object in the table', () => { + const counter = cg.getNodesByKind('route').find((r) => r.name === '/counter')!; + expect(counter.filePath).toBe('src/Web/ClientApp/src/AppRoutes.js'); + expect(counter.startLine).toBe(15); + }); + + it('makes no route of a menu, a list nothing hands the router, or a path the table does not hold', () => { + for (const p of ['/help', '/settings', '/history', '/stats', '/admin/stats']) expect(routeNames(cg)).not.toContain(p); + }); + + it('gives the navigation that names those routes somewhere to go', () => { + expect(navigations(cg)).toEqual([ + 'NavMenu -> /', + 'NavMenu -> /counter', + 'NavMenu -> /fetch-data', + 'showProfile -> /authentication/profile', + ]); + }); + + it('lands on the Screens tab', async () => { + const screens = await buildScreens(cg, root); + expect(screens.routed).toBe(true); + expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']); + }); +}); + +describe('react-router: a table handed to useRoutes from another file, an index route under a layout', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'kit', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + // A table written as a function of the login state, the way many admin kits write it. + 'src/routes.jsx': `import { Navigate } from 'react-router-dom'; +import DashboardLayout from './layouts/DashboardLayout'; +import MainLayout from './layouts/MainLayout'; +import { Account, Dashboard, Landing, Login } from './pages'; + +const routes = (isLoggedIn) => [ + { + path: 'app', + element: isLoggedIn ? : , + children: [ + { path: 'dashboard', element: }, + { path: 'account', element: }, + ], + }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'login', element: }, + ], + }, +]; + +export default routes; +`, + 'src/App.jsx': `import { useRoutes } from 'react-router-dom'; +import routes from './routes'; + +export default function App({ isLoggedIn }) { + return useRoutes(routes(isLoggedIn)); +} +`, + 'src/layouts/MainLayout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function MainLayout() { + return
Dashboard
; +} +`, + 'src/layouts/DashboardLayout.jsx': `import { Outlet } from 'react-router-dom'; +export default function DashboardLayout() { + return ; +} +`, + 'src/pages.jsx': `import { useNavigate } from 'react-router-dom'; +export function Dashboard() { return
Dashboard
; } +export function Account() { return
Account
; } +export function Landing() { return
Landing
; } +export function Login() { + const navigate = useNavigate(); + const onSubmit = () => navigate('/app/account'); + return
; +} +`, + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads the table the function returns, nested paths composed', () => { + expect(routeBindings(cg)).toEqual([ + // The index route is the page at `/`; MainLayout is the layout around it, not a second `/`. + '/ -> Landing', + '/ ~> MainLayout', + // `app`'s element is a choice made at runtime: a path to sit under, no layout. + '/app/account -> Account', + '/app/dashboard -> Dashboard', + '/login -> Login', + '/login ~> MainLayout', + ]); + }); + + it("draws a layout's links on every screen inside it", async () => { + expect(navigations(cg)).toEqual([ + 'MainLayout -> /app/dashboard', + 'onSubmit -> /app/account', + // The table's own guard: `isLoggedIn ? : `. + 'routes -> /login', + ]); + const screens = await buildScreens(cg, root); + expect(screens.routed).toBe(true); + const at = (p: string) => screens.screens.find((s) => s.path === p)!.id; + for (const from of ['/', '/login']) { + expect(screens.links.find((l) => l.from === at(from) && l.to === at('/app/dashboard'))).toBeDefined(); + } + expect(screens.links.find((l) => l.from === at('/login') && l.to === at('/app/account'))).toBeDefined(); + }); +}); + +describe("react-router: route objects in their own files, handed to createBrowserRouter (codedthemes' admin templates)", () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'berry', dependencies: { react: '^18', 'react-router-dom': '^7' } }), + 'src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom'; + +// routes +import AuthenticationRoutes from './AuthenticationRoutes'; +import MainRoutes from './MainRoutes'; + +const router = createBrowserRouter([MainRoutes, AuthenticationRoutes], { + basename: import.meta.env.VITE_APP_BASE_NAME +}); + +export default router; +`, + 'src/routes/MainRoutes.jsx': `import { lazy } from 'react'; +import MainLayout from '../layout/MainLayout'; +import Loadable from '../ui-component/Loadable'; + +const DashboardDefault = Loadable(lazy(() => import('../views/DashboardDefault'))); +// An import only the app's own build resolves (\`baseUrl\`). +const SamplePage = Loadable(lazy(() => import('views/sample-page'))); + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: '/', + element: + }, + { + path: 'dashboard', + children: [ + { + path: 'default', + element: + } + ] + }, + { + path: '/sample-page', + element: + } + ] +}; + +export default MainRoutes; +`, + 'src/routes/AuthenticationRoutes.jsx': `import MinimalLayout from '../layout/MinimalLayout'; +import LoginPage from '../views/LoginPage'; + +const AuthenticationRoutes = { + path: '/', + element: , + children: [ + { + path: '/pages/login', + element: + } + ] +}; + +export default AuthenticationRoutes; +`, + 'src/layout/MainLayout.jsx': 'export default function MainLayout() {\n return
;\n}\n', + 'src/layout/MinimalLayout.jsx': 'export default function MinimalLayout() {\n return
;\n}\n', + 'src/views/DashboardDefault.jsx': 'export default function DashboardDefault() {\n return
;\n}\n', + 'src/views/LoginPage.jsx': 'export default function LoginPage() {\n return
;\n}\n', + 'src/ui-component/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => ;\n}\n', + // The repository's other app, with pages of the same names. + 'next/package.json': JSON.stringify({ name: 'berry-next', dependencies: { next: '^15', react: '^18' } }), + 'next/src/views/sample-page.jsx': 'export default function SamplePage() {\n return
;\n}\n', + 'next/src/views/default.jsx': 'export default function DashboardDefault() {\n return
;\n}\n', + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads each route object the array names, its children under its path', () => { + expect(routeBindings(cg)).toEqual([ + // MainRoutes' `/` child claims its layout's address. + '/ -> DashboardDefault', + '/ ~> MainLayout', + // No child of the login layout claims `/`, so it is a page there of its + // own — the rule Angular and Vue routes follow. + '/ -> MinimalLayout', + '/dashboard/default -> DashboardDefault', + '/dashboard/default ~> MainLayout', + '/pages/login -> LoginPage', + '/pages/login ~> MinimalLayout', + '/sample-page -> SamplePage', + '/sample-page ~> MainLayout', + ].sort()); + }); + + it('binds a page the route file loads lazily to the module it loads, and never to a same-named page of another app', () => { + const rendered = (p: string): string[] => cg.getNodesByKind('route').filter((r) => r.name === p).flatMap((r) => cg.getOutgoingEdges(r.id) + .filter((e) => e.kind === 'references' && !(e.metadata as Record | undefined)?.layout) + .map((e) => { const n = cg.getNode(e.target)!; return `${n.kind} ${n.name} ${n.filePath}`; })); + expect(rendered('/dashboard/default')).toEqual(['function DashboardDefault src/views/DashboardDefault.jsx']); + // Its module is out of reach, so the page is the declaration itself. + expect(rendered('/sample-page')).toEqual(['constant SamplePage src/routes/MainRoutes.jsx']); + }); +}); + +describe('react-router: a table mapped inside in the same file', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'ClientApp/package.json': JSON.stringify({ name: 'client', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'ClientApp/src/routes.js': `import { Routes, Route } from 'react-router-dom'; +import DashboardLayout from './layouts/dashboard'; +import DashboardApp from './pages/DashboardApp'; +import LandingPage from './pages/LandingPage'; +import TicketDetail from './pages/TicketDetail'; + +const DashboardRoutes = [ + { + path: 'app', + requireAuth: true, + element: + }, + { + path: 'tickets/:id', + requireAuth: true, + element: + } +]; + +export default function Router() { + return ( + + }/> + }> + {DashboardRoutes.map((route, index) => { + const {element, requireAuth, ...rest} = route; + return ; + })} + + + ); +} +`, + 'ClientApp/src/layouts/dashboard.js': 'export default function DashboardLayout() {\n return
;\n}\n', + 'ClientApp/src/pages/DashboardApp.js': 'export default function DashboardApp() {\n return
;\n}\n', + 'ClientApp/src/pages/LandingPage.js': 'export default function LandingPage() {\n return
;\n}\n', + 'ClientApp/src/pages/TicketDetail.js': 'export default function TicketDetail() {\n return
;\n}\n', + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("composes the table's relative paths onto the it is mapped inside", () => { + expect(routeBindings(cg)).toEqual([ + '/dashboard -> DashboardLayout', + '/dashboard/app -> DashboardApp', + '/dashboard/tickets/:id -> TicketDetail', + '/landing -> LandingPage', + ]); + }); +}); + +describe('react-router: a route table as the files around it change', () => { + let root: string; + let cg: CodeGraph; + const app = (body: string) => `import { Route, Routes } from 'react-router-dom'; +import AppRoutes from './AppRoutes'; +import { Counter } from './Counter'; + +export default function App() { + return ( + + ${body} + + ); +} +`; + const MAPPED = app('{AppRoutes.map(({ element, ...rest }, index) => )}'); + const table = (extra: string) => `import { Counter } from './Counter'; +import { Home } from './Home'; +import { FetchData } from './FetchData'; + +const AppRoutes = [${extra} + { index: true, element: }, + { path: '/counter', element: }, +]; + +export default AppRoutes; +`; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/AppRoutes.js': table(''), + 'src/App.js': MAPPED, + 'src/Home.js': component('Home'), + 'src/Counter.js': component('Counter'), + 'src/FetchData.js': component('FetchData'), + 'src/NavMenu.js': `import { Link, useNavigate } from 'react-router-dom'; +export function NavMenu() { + const navigate = useNavigate(); + const goHome = () => navigate('/'); + return ; +} +`, + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('starts with the table read', () => { + expect(routeNames(cg)).toEqual(['/', '/counter']); + expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']); + }); + + it('an edit to the table moves its routes, and what navigated to them still does', async () => { + writeFiles(root, { 'src/AppRoutes.js': table("\n { path: '/fetch-data', element: },") }); + await cg.sync(); + expect(routeBindings(cg)).toEqual(['/ -> Home', '/counter -> Counter', '/fetch-data -> FetchData']); + expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']); + }); + + it('a file that stops handing the table over takes its routes with it, and putting it back brings them back', async () => { + writeFiles(root, { 'src/App.js': app('} />') }); + await cg.sync(); + expect(routeNames(cg)).toEqual(['/static']); + writeFiles(root, { 'src/App.js': MAPPED }); + await cg.sync(); + expect(routeNames(cg)).toEqual(['/', '/counter', '/fetch-data']); + expect(navigations(cg)).toContain('NavMenu -> /counter'); + }); + + it('deleting that file is a change too', async () => { + fs.rmSync(path.join(root, 'src/App.js')); + await cg.sync(); + expect(routeNames(cg)).toEqual([]); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index e08bb4426..c4a3765d2 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) 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-06) 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) | +| 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); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); 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) | | 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) | @@ -263,6 +263,26 @@ Each of these cost real debugging time; they are not hypothetical. template-bound handler there was listed as dead code. A new detector that gates on a dependency should ask `dependsOn`, not read a `package.json` itself, so it sees the same manifests every other detector does. +13. **A route table can be named only by another file.** The ASP.NET Core + React template keeps its routes in `AppRoutes.js`, a file that never names + the router, and renders them from `App.js` with `AppRoutes.map(({ element, + ...rest }) => )`; codedthemes' admin + kits put one route object per file and list them in + `createBrowserRouter([MainRoutes, LoginRoutes])`. Read on its own, such a + file cannot be told from a menu's `{ path, element }` list, and per-file + extraction cannot see the file that hands it over, nor run again when that + file changes. So React keeps those routes in the cross-file pass + (`FrameworkResolver.crossFileNodes`, reconciled in `runPostExtract` on every + index and sync, removals included): it starts from the files that import + React Router, takes only a `useRoutes` / `create*Router` argument or a + `.map` whose callback renders a `` from the item's own fields + (`{...route}`, `{...rest}`, `path={route.path}` — never + `path={r.layout + r.path}`), follows the import to the table and the tables + it names in turn, and owns its routes by id (`…:table:`). Two limits + 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, and a navigation that failed while its route was gone is not + retried until a full index. --- diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 3ef374bcb..4afa667d6 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -141,6 +141,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel - **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge. +- **React Router apps that keep their routes in a table get a Screens picture.** The ASP.NET Core React template's `AppRoutes` array, a `routes` array handed to `useRoutes` or `createBrowserRouter` from another file, and admin kits' `[MainRoutes, LoginRoutes]` route objects drew no screens, because their routes were never read. They are now, with the links and `navigate(…)` calls between them. A parent route object's component is drawn as the layout around the screens inside it: its own links count from each of those screens, and when an `index: true` child claims its address, that child is the screen there. Re-index React projects after upgrading. + ## Fixes — Steps — what a call does, and when - **A Steps picture with nothing past its anchor says why.** Starting Steps from a helper that only computes drew one box and no explanation. The panel now says that nothing it sets in motion is a step the picture draws, explains that calls between plain functions fold into the lines, and links to the symbol's callers and callees. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 51445af38..8566e0398 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -17,7 +17,6 @@ import { ExtractionError, Edge, UnresolvedReference, - ReferenceKind, } from '../types'; import { QueryBuilder, NodeIdentity } from '../db/queries'; import { extractFromSource } from './tree-sitter'; @@ -35,6 +34,7 @@ import { detectFrameworks, getFrameworkResolver } from '../resolution/frameworks import { declaredDependencies } from '../resolution/frameworks/package-deps'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; +import { resurrectRefFromDroppedEdge } from '../resolution/resurrect-ref'; import { MAX_SOURCE_FILE_SIZE_BYTES, oversizeStamp, readBoundedSource, readBoundedSourceSync } from '../file-limits'; export { oversizeStamp }; @@ -1771,38 +1771,6 @@ function scanDirectoryWalk( return files; } -/** - * Resurrect a resolution edge that is about to be dropped (its target symbol - * was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved - * reference that created it, read from the refName/refKind stamp - * `createEdges` writes into edge metadata. Inserted as status='pending', the - * ref is consumed by the same sync's resolution sweep: it rebinds to an - * alternative definition if one exists, or parks as status='failed' where the - * #1240 retry finds it if the symbol later reappears. - * - * Returns null — drop silently, the pre-#1240 behavior — for edges without a - * refName stamp (created before the stamp existed, or synthesized): rebuilding - * a ref from the target's plain node name would strip the receiver/qualifier - * context the original text carried (`h.greet` → `greet`) and could rebind - * somewhere a full re-index never would. Silent beats wrong. - */ -function resurrectRefFromDroppedEdge( - e: Edge & { sourceFilePath: string; sourceLanguage: Language } -): UnresolvedReference | null { - const refName = e.metadata?.refName; - if (typeof refName !== 'string' || refName.length === 0) return null; - const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind; - return { - fromNodeId: e.source, - referenceName: refName, - referenceKind: refKind, - line: e.line ?? 0, - column: e.column ?? 0, - filePath: e.sourceFilePath, - language: e.sourceLanguage, - }; -} - /** * What tells a file's nodes apart across a re-index, coarsest first. (kind, * name) is the #899 key and settles every name a file defines once; a name it diff --git a/src/index.ts b/src/index.ts index c3b8d4f7b..70b7acfe6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -940,18 +940,16 @@ export class CodeGraph { // Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on // every sync that touched files so edits to `app.module.ts` propagate // to controllers in unchanged files. The pass is idempotent and cheap - // (regex over *.module.ts only). - if (result.filesAdded > 0 || result.filesModified > 0) { + // (regex over *.module.ts only). A removal counts too: deleting the + // file that hands a React Router table to the router leaves the + // table's routes behind unless this pass runs to take them away. + // (A pure-removal sync still resolves refs below — the deletion path + // resurrects the removed file's incoming edges as pending refs, #1240 + // removal case — and runPostExtract starts by dropping the resolver's + // name caches, which a long-lived daemon warmed against the + // pre-removal graph.) + if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) { this.resolver.runPostExtract(); - } else if (result.filesRemoved > 0) { - // A pure-removal sync still resolves refs below — the deletion path - // resurrects the removed file's incoming edges as pending refs - // (#1240 removal case) and the orphan sweep consumes them. In a - // long-lived process (daemon) the resolver's name caches were - // warmed against the pre-removal graph; drop them so resolution - // sees the post-removal state. (runPostExtract above clears caches - // itself, so the changed-files branch is already covered.) - this.resolver.clearCaches(); } // Resolve references if files were updated diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 699388978..01e8562cd 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -5,8 +5,11 @@ * `` (v5), * `}/>` (v6) and * `createBrowserRouter([{ path, element }])` (v6.4+) each become a `route` - * node named by its path, bound to the component that renders it. That is - * half of what "how does this app flow" means. This file is the other half. + * node named by its path, bound to the component that renders it — and so + * does a table another file hands the router (`AppRoutes.map(… )`, `useRoutes(routes)` with `routes` imported), read once every + * file is indexed. That is half of what "how does this app flow" means. This + * file is the other half. * * **Navigation is a string.** `history.push('/placeorder')` (v5, and the * `useHistory` hook), `navigate('/placeorder')` (v6's `useNavigate`), @@ -32,7 +35,9 @@ * inside ``, a data router's `children`); the markup * scan composes that tree, a constant path (`paths.app.root.path`) included, * and a `lazy: () => import('./routes/x')` route renders that module's default - * export (`frameworks/react.ts`). Known limit, deliberate: a splat + * export (`frameworks/react.ts`). An `index: true` route is the page at its + * parent's address, and a route object around others is the layout they + * render inside (a `references` edge marked `layout: true`). Known limit, deliberate: a splat * (`/admin/*`) matches anything, so it is never the answer to a concrete href. */ @@ -54,7 +59,7 @@ import { // two arms). It lives in `nextjs.ts` because that is where it was first // needed; duplicating it here would be a second derivation of the same rule. import { destinationsForHref } from './nextjs'; -import { configHrefExpression } from './react'; +import { configHrefExpression, isTableRoute } from './react'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; @@ -73,11 +78,13 @@ export const reactRouterRoot = appRootFor; * Its id is a verbatim reconstruction of the node's own fields, which no * other framework's route id is: a server route carries its METHOD * (`route:file:12:POST:/login`), a file-based page carries no line. A path - * built from a constant keeps the id it was extracted with. + * built from a constant keeps the id it was extracted with, and a route read + * from a table another file hands the router says so (`…:12:table:/login`). */ function isReactRouterRoute(node: Node): boolean { const prefix = `route:${node.filePath}:${node.startLine}:`; if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false; + if (isTableRoute(node)) return true; // Its name, or — for a path built from a constant, renamed after extraction // — the path as the file wrote it; never a server route's `METHOD:`. const rest = node.id.slice(prefix.length); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 55bbec581..72d05c91d 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -5,10 +5,14 @@ * Next.js pages, route handlers and navigation are `nextjs.ts`'s. */ -import { Node } from '../../types'; -import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { Language, Node } from '../../types'; +import { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; import { resolveImportPath } from '../import-resolver'; +import { stripCommentsForRegex } from '../strip-comments'; +import { makeLineAt } from '../synth-utils'; +import { isTestPath } from '../../search/query-utils'; +import { matchBracket, skipString } from './object-literal'; /** The languages React components, hooks and contexts are written and used in. */ const REACT_SCRIPT_LANGUAGES: ReadonlySet = new Set(['typescript', 'javascript', 'tsx', 'jsx']); @@ -31,9 +35,10 @@ export const reactResolver: FrameworkResolver = { return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx')); }, - // A data-router `lazy: () => import('./routes/x')` route names a module, not a symbol. + // A data-router `lazy: () => import('./routes/x')` route names a module, not + // a symbol; a route's `layout:MainLayout` names the component around it. claimsReference(name: string): boolean { - return name.startsWith(LAZY_ROUTE_PREFIX); + return name.startsWith(LAZY_ROUTE_PREFIX) || name.startsWith(LAYOUT_PREFIX); }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { @@ -44,6 +49,12 @@ export const reactResolver: FrameworkResolver = { const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; } + // The layout a route renders inside: what happens in it — its header's + // links, its logout — happens on the route's screen too. + if (ref.referenceName.startsWith(LAYOUT_PREFIX)) { + const target = layoutComponent(ref.referenceName.slice(LAYOUT_PREFIX.length), ref, context); + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework', metadata: { layout: true } } : null; + } // A component, hook or context the file IMPORTS is the import's: the // package's (`useQuery` from `@tanstack/react-query`, `