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`, `