diff --git a/CHANGELOG.md b/CHANGELOG.md index bfedee281..9aff779fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) +- React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index 1ae76ff78..9c2cf86e1 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -516,9 +516,10 @@ describe('react-router: route declaration boundaries (#1348)', () => { const routes = cg.getNodesByKind('route'); return { paths: routes.map((route) => route.name).sort(), + // `->` what a route renders, `~>` a layout it renders inside. bindings: routes.flatMap((route) => cg!.getOutgoingEdges(route.id) .filter((edge) => edge.kind === 'references') - .map((edge) => `${route.name}->${cg!.getNode(edge.target)?.name}`)).sort(), + .map((edge) => `${route.name}${(edge.metadata as Record | undefined)?.layout ? '~>' : '->'}${cg!.getNode(edge.target)?.name}`)).sort(), }; } @@ -548,9 +549,11 @@ describe('react-router: route declaration boundaries (#1348)', () => { } `, extension); expect(result).toEqual({ - // A nested route's path is relative to its parent's (`settings` under `/dashboard`). + // A nested route's path is relative to its parent's (`settings` under + // `/dashboard`). The index route is the page at `/dashboard`; the `` around it only groups, so it is not a second, empty `/dashboard`. paths: ['/dashboard', '/dashboard/settings', '/empty', '/legacy', '/long', '/no-element', '/sibling'], - bindings: ['/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'], + bindings: ['/dashboard->DashboardHome', '/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'], }); }); @@ -580,6 +583,133 @@ describe('react-router: route declaration boundaries (#1348)', () => { }); }); + it.each(['tsx', 'jsx', 'js'])('reads what a route renders past placeholders, guards and line breaks in %s', async (extension) => { + const result = await index(` + import { Suspense } from 'react'; + import { Routes, Route } from 'react-router-dom'; + function Loader() { return null; } + function AdminPanel() { return null; } + function RequireAuth({ children }) { return children; } + function ProtectedPage() { return null; } + function PrivateRoot({ component }) { return component; } + function HomePage() { return null; } + function AuthPage() { return null; } + function RememberMe() { return null; } + export function App() { + return + }>} /> + + + + } + /> + } />} /> + }>} /> + } />} /> + ; + } + `, extension); + expect(result.bindings).toEqual([ + // A `fallback` is shown while the page loads; it is not the page. + '/admin->AdminPanel', + // A page handed to a guard as a prop. + '/home->HomePage', + // `AuthPage` reads like a guard's name (`Auth…`); when every tag does, the innermost is the page. + '/login->AuthPage', + // Prettier writes a long element on lines of its own. + '/protected->ProtectedPage', + // Only a `component`, `element` or `page` prop hands over a page. + '/signin->AuthPage', + ]); + }); + + it('reads an index route only where its parent’s address is written down', async () => { + const result = await index(` + import { Routes, Route } from 'react-router-dom'; + import { paths } from './paths'; + function Navigation() { return null; } + function Home() { return null; } + function CategoriesPreview() { return null; } + function Category() { return null; } + function ListAgents() { return null; } + function ListWorkspaces() { return null; } + // Mounted at \`shop/*\` below: its index is the page at \`/shop\`, which its own does not say. + function Shop() { + return + } /> + } /> + ; + } + export function App() { + return + }> + } /> + } /> + + } /> + + + } /> + + + ; + } + `, 'jsx'); + expect(result.paths.filter((p) => p === '/')).toEqual(['/']); + expect(result.bindings).toContain('/->Home'); + // Neither a component's own nor a path the file does not spell out says where these are. + expect(result.bindings.filter((b) => /CategoriesPreview|ListAgents/.test(b))).toEqual([]); + // A path in braces is still written down. + expect(result.bindings).toContain('/workspaces->ListWorkspaces'); + }); + + it.each(['tsx', 'jsx'])('reads an index route at the top of the router itself as `/` in %s', async (extension) => { + const result = await index(` + import { BrowserRouter, createBrowserRouter, createRoutesFromElements, Routes, Route } from 'react-router-dom'; + function Home() { return null; } + function About() { return null; } + function Dashboard() { return null; } + export function App() { + return + + } /> + } /> + + ; + } + export const router = createBrowserRouter(createRoutesFromElements(} />)); + `, extension); + expect(result).toEqual({ paths: ['/', '/', '/about'], bindings: ['/->Dashboard', '/->Home', '/about->About'] }); + }); + + it('keeps a version 5 catch-all out, and reads a version 3 parent route as the layout around its children', async () => { + const result = await index(` + import { Router, Route, Switch } from 'react-router'; + function App() { return null; } + function About() { return null; } + function NotFound() { return null; } + export const routes = ( + + + + + + + + + + ); + `, 'jsx'); + expect(result).toEqual({ + // No child claims `/`, so App is the page there as well as the layout around `/about`. + paths: ['/', '/about'], + bindings: ['/->App', '/about->About', '/about~>App'], + }); + }); + it('keeps nested JSX and comma-containing expressions inside their data-router property', async () => { const result = await index(` import { createMemoryRouter } from 'react-router-dom'; @@ -731,6 +861,9 @@ const routeNames = (cg: CodeGraph): string[] => cg.getNodesByKind('route').map(( const component = (name: string): string => `export function ${name}() {\n return
${name}
;\n}\n`; +/** A screen file whose component is its default export. */ +const screen = (name: string): string => `export default 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' } }), @@ -1131,6 +1264,8 @@ describe('react-router: a table mapped inside in the same file', () ({ 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 AdminLayout from './layouts/admin'; +import { adminRoutes } from './adminRoutes'; import DashboardLayout from './layouts/dashboard'; import DashboardApp from './pages/DashboardApp'; import LandingPage from './pages/LandingPage'; @@ -1159,14 +1294,23 @@ export default function Router() { return ; })} + }> + {adminRoutes.map((route) => )} +
); } +`, + 'ClientApp/src/adminRoutes.js': `import Reports from './pages/Reports'; + +export const adminRoutes = [{ path: 'reports', element: }]; `, 'ClientApp/src/layouts/dashboard.js': 'export default function DashboardLayout() {\n return
;\n}\n', + 'ClientApp/src/layouts/admin.js': 'export default function AdminLayout() {\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', + 'ClientApp/src/pages/Reports.js': 'export default function Reports() {\n return
;\n}\n', })); }); afterAll(() => { @@ -1174,11 +1318,17 @@ export default function Router() { if (root) fs.rmSync(root, { recursive: true, force: true }); }); - it("composes the table's relative paths onto the it is mapped inside", () => { + it("composes the table's relative paths onto the it is mapped inside, and renders them inside its element", () => { expect(routeBindings(cg)).toEqual([ + '/admin -> AdminLayout', + // A table written in another file keeps its routes there, where a layout + // named in this file would be looked up: they get no layout edge. + '/admin/reports -> Reports', '/dashboard -> DashboardLayout', '/dashboard/app -> DashboardApp', + '/dashboard/app ~> DashboardLayout', '/dashboard/tickets/:id -> TicketDetail', + '/dashboard/tickets/:id ~> DashboardLayout', '/landing -> LandingPage', ]); }); @@ -1261,3 +1411,346 @@ export function NavMenu() { expect(routeNames(cg)).toEqual([]); }); }); + +// ============================================================================= +// JSX index routes, and the around other routes +// ============================================================================= + +/** React Router 6's own examples: a layout route at `/`, an index route inside it. */ +const JSX_LAYOUTS: Record = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/App.jsx': `import { Routes, Route } from 'react-router-dom'; +import Layout from './Layout'; +import DashboardLayout from './DashboardLayout'; +import AdminLayout from './AdminLayout'; +import RequireAuth from './RequireAuth'; +import { Home, About, Login, DashboardHome, Stats, Account, Settings, NoMatch, Users } from './pages'; + +export default function App() { + return ( + + }> + } /> + } /> + } /> + }> + } /> + } /> + + }> + } /> + + + } /> + + } /> + + }> + } /> + + + ); +} +`, + 'src/Layout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function Layout() { + return ( +
+ + +
+ ); +} +`, + 'src/DashboardLayout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function DashboardLayout() { + return
Stats
; +} +`, + 'src/AdminLayout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function AdminLayout() { + return
Users
; +} +`, + 'src/RequireAuth.jsx': `import { Navigate, Outlet } from 'react-router-dom'; +export default function RequireAuth({ user }) { + return user ? : ; +} +`, + 'src/pages.jsx': `import { useNavigate } from 'react-router-dom'; +export function Home() { return

Home

; } +export function About() { return

About

; } +export function Login() { return

Login

; } +export function DashboardHome() { return

Dashboard

; } +export function Stats() { return

Stats

; } +export function Account() { + const navigate = useNavigate(); + const signOut = () => navigate('/'); + return ; +} +export function Settings() { return

Settings

; } +export function NoMatch() { return

Nothing here

; } +export function Users() { return

Users

; } +`, +}; + +describe('react-router: a JSX index route is the page at its parent’s address, and the around it the layout', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject(JSX_LAYOUTS)); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('binds each address to its page, inside every layout around it', () => { + expect(routeBindings(cg)).toEqual([ + '/ -> Home', + '/ ~> Layout', + '/* -> NoMatch', + '/* ~> Layout', + '/about -> About', + '/about ~> Layout', + // A `` with no path is a layout at no address of its own. + '/account -> Account', + '/account ~> Layout', + '/account ~> RequireAuth', + // No child claims `/admin`, so AdminLayout is the page there as well. + '/admin -> AdminLayout', + '/admin/users -> Users', + '/admin/users ~> AdminLayout', + '/dashboard -> DashboardHome', + '/dashboard ~> DashboardLayout', + '/dashboard ~> Layout', + '/dashboard/stats -> Stats', + '/dashboard/stats ~> DashboardLayout', + '/dashboard/stats ~> Layout', + '/login -> Login', + '/login ~> Layout', + // `` renders nothing: it only groups its index route. + '/settings -> Settings', + '/settings ~> Layout', + ].sort()); + }); + + it('puts one route at each address', () => { + expect(routeNames(cg)).toEqual(['/', '/*', '/about', '/account', '/admin', '/admin/users', '/dashboard', '/dashboard/stats', '/login', '/settings']); + const home = cg.getNodesByKind('route').find((r) => r.name === '/')!; + expect(home.startLine).toBe(12); + }); + + it('lands a navigation to a layout’s address on the page there', () => { + expect(navigations(cg)).toEqual([ + 'AdminLayout -> /admin/users', + 'DashboardLayout -> /dashboard/stats', + 'Layout -> /', + 'Layout -> /about', + 'Layout -> /dashboard', + 'RequireAuth -> /login', + 'signOut -> /', + ]); + }); + + it('draws a layout’s links on every screen inside it, and only there', async () => { + const screens = await buildScreens(cg, root); + expect(screens.routed).toBe(true); + const at = (p: string) => screens.screens.find((s) => s.path === p)!.id; + const linked = (from: string, to: string) => screens.links.some((l) => l.from === at(from) && l.to === at(to)); + // Layout's nav bar is on every page it wraps, the deepest included. + for (const from of ['/', '/about', '/login', '/dashboard', '/dashboard/stats', '/account', '/settings']) { + expect(linked(from, '/about')).toBe(true); + } + expect(linked('/dashboard/stats', '/dashboard')).toBe(true); + // The guard's redirect happens on the page it guards. + expect(linked('/account', '/login')).toBe(true); + expect(linked('/about', '/login')).toBe(false); + // The admin pages are not inside Layout. + expect(linked('/admin/users', '/about')).toBe(false); + expect(linked('/admin', '/admin/users')).toBe(true); + }); +}); + +describe('react-router: createRoutesFromElements with an index route and guards at path="" (proshop-v2)', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'proshop', private: true }), + 'frontend/package.json': JSON.stringify({ name: 'frontend', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'frontend/src/index.js': `import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router-dom'; +import App from './App'; +import PrivateRoute from './components/PrivateRoute'; +import AdminRoute from './components/AdminRoute'; +import HomeScreen from './screens/HomeScreen'; +import CartScreen from './screens/CartScreen'; +import LoginScreen from './screens/LoginScreen'; +import ShippingScreen from './screens/ShippingScreen'; +import OrderListScreen from './screens/admin/OrderListScreen'; + +const router = createBrowserRouter( + createRoutesFromElements( + }> + } /> + } /> + } /> + {/* Registered users */} + }> + } /> + + {/* Admin users */} + }> + } /> + + + ) +); + +export default function Root() { + return ; +} +`, + 'frontend/src/App.js': `import { Outlet } from 'react-router-dom'; +import Header from './components/Header'; +export default function App() { + return ( + <> +
+
+ + ); +} +`, + 'frontend/src/components/Header.js': `import { Link } from 'react-router-dom'; +export default function Header() { + return
Cart
; +} +`, + 'frontend/src/components/PrivateRoute.js': `import { Navigate, Outlet } from 'react-router-dom'; +export default function PrivateRoute({ userInfo }) { + return userInfo ? : ; +} +`, + 'frontend/src/components/AdminRoute.js': `import { Navigate, Outlet } from 'react-router-dom'; +export default function AdminRoute({ userInfo }) { + return userInfo && userInfo.isAdmin ? : ; +} +`, + 'frontend/src/screens/HomeScreen.js': screen('HomeScreen'), + 'frontend/src/screens/CartScreen.js': screen('CartScreen'), + 'frontend/src/screens/LoginScreen.js': screen('LoginScreen'), + 'frontend/src/screens/ShippingScreen.js': screen('ShippingScreen'), + 'frontend/src/screens/admin/OrderListScreen.js': screen('OrderListScreen'), + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('makes HomeScreen the page at `/` and App the layout around every screen, the guards around theirs', () => { + expect(routeBindings(cg)).toEqual([ + '/ -> HomeScreen', + '/ ~> App', + '/admin/orderlist -> OrderListScreen', + '/admin/orderlist ~> AdminRoute', + '/admin/orderlist ~> App', + '/cart -> CartScreen', + '/cart ~> App', + '/login -> LoginScreen', + '/login ~> App', + '/shipping -> ShippingScreen', + '/shipping ~> App', + '/shipping ~> PrivateRoute', + ]); + // Neither App nor a guard is a second page at `/`. + expect(routeNames(cg)).toEqual(['/', '/admin/orderlist', '/cart', '/login', '/shipping']); + }); + + it('draws the header’s link from every screen, and a guard’s redirect from the screens it guards', async () => { + const screens = await buildScreens(cg, root); + const at = (p: string) => screens.screens.find((s) => s.path === p)!.id; + const linked = (from: string, to: string) => screens.links.some((l) => l.from === at(from) && l.to === at(to)); + for (const from of ['/', '/login', '/shipping', '/admin/orderlist']) expect(linked(from, '/cart')).toBe(true); + expect(linked('/shipping', '/login')).toBe(true); + expect(linked('/admin/orderlist', '/login')).toBe(true); + expect(linked('/cart', '/login')).toBe(false); + }); +}); + +describe('react-router: JSX index routes as the route file changes', () => { + let root: string; + let cg: CodeGraph; + const app = (index: boolean) => `import { Routes, Route } from 'react-router-dom'; +import Layout from './Layout'; +import { Home, About } from './pages'; + +export default function App() { + return ( + + }>${index ? '\n } />' : ''} + } /> + + + ); +} +`; + const files = (index: boolean): Record => ({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/App.jsx': app(index), + 'src/Layout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function Layout() { + return
About
; +} +`, + 'src/pages.jsx': `import { useNavigate } from 'react-router-dom'; +export function Home() { return

Home

; } +export function About() { + const navigate = useNavigate(); + const goHome = () => navigate('/'); + return ; +} +`, + }); + /** What a fresh index of the same files says. */ + const fresh = async (index: boolean): Promise<{ bindings: string[]; navigations: string[] }> => { + const project = await indexProject(files(index)); + try { + return { bindings: routeBindings(project.cg), navigations: navigations(project.cg) }; + } finally { + project.cg.close(); + fs.rmSync(project.root, { recursive: true, force: true }); + } + }; + beforeAll(async () => { + ({ root, cg } = await indexProject(files(false))); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('starts with the layout as the page at its own address', () => { + expect(routeBindings(cg)).toEqual(['/ -> Layout', '/about -> About', '/about ~> Layout']); + expect(navigations(cg)).toEqual(['Layout -> /about', 'goHome -> /']); + }); + + // Each also indexes the same files afresh, to compare with. + it('an index route added takes the address, and what navigated there follows it', async () => { + writeFiles(root, { 'src/App.jsx': app(true) }); + await cg.sync(); + expect(routeBindings(cg)).toEqual(['/ -> Home', '/ ~> Layout', '/about -> About', '/about ~> Layout']); + expect({ bindings: routeBindings(cg), navigations: navigations(cg) }).toEqual(await fresh(true)); + }, 60_000); + + it('taking it away gives the address back to the layout', async () => { + writeFiles(root, { 'src/App.jsx': app(false) }); + await cg.sync(); + expect({ bindings: routeBindings(cg), navigations: navigations(cg) }).toEqual(await fresh(false)); + }, 60_000); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index c4a3765d2..dfaa9b0ce 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-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-07) 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); 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) | +| 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). 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 | | 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) | @@ -283,6 +283,29 @@ Each of these cost real debugging time; they are not hypothetical. 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. +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 + layout of every route nested in it (trap 2: emitting Layout as a second + `/` page drew the app's chrome as the home screen and its header's links + from `/` alone). A `` with no element only groups its children + and yields its address the same way. But a `` inside a component + is mounted wherever another file's `}>` puts it: read as `/`, crwn-clothing's `Shop` index made a second + home page. So an index route at the top of a `` is read only + inside the router itself (`createRoutesFromElements(…)`, ``), + and one under a path the file does not spell out (`path={paths.agents}`) + not at all; `path={"agents"}` is spelled out. Reading what an element + renders has three traps of its own: skip elements written in an attribute + unless the attribute hands over a page (`component`, `element`, `page`) — + `}>` bound fifteen routes of one app to the + spinner; trim the line break Prettier writes after `element={`, which hid + every long element; and read `` as rendering nothing of its own. + Version 5's `` is a catch-all, not + the page at its parent's address. A table written in another file and + mapped inside a `` gets no layout edge: the layout's name + would be looked up in the table's file, and the cross-file pass compares + only route names, so a changed layout would never reach the table's routes. --- diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 6e9049b85..be57f4842 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -143,6 +143,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel - **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. +- **A React Router layout written as JSX is drawn around every screen inside it.** In `}>} />…`, the screen at `/` is Home rather than Layout, and the links in Layout's header count from each screen nested in it instead of only from `/`. A guard with no path of its own, like `}>`, does the same for its redirect to the login page. 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/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 01e8562cd..59404a643 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -35,10 +35,14 @@ * 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`). 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. + * export (`frameworks/react.ts`). An index route (`index: true`, ``) is the page at its parent's address, and a route around others — + * an object with `children`, a `` with ``s inside — is + * the layout they render inside (a `references` edge marked `layout: true`). + * Known limits, deliberate: a splat (`/admin/*`) matches anything, so it is + * never the answer to a concrete href; and an index route at the top of a + * component's own `` is at wherever another route mounts that + * component, which its file does not say, so it is no route at all. */ import type { Language, Node } from '../../types'; diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 72d05c91d..293655d72 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -473,10 +473,18 @@ interface RouteScope { end: number; component?: string; lazy?: string; - /** A `` element: a route even with nothing to render (a layout's path). */ + /** A `` element: a route even with nothing to render (a `` that only groups others). */ jsx?: boolean; - /** `{ element: , children }`, with neither `path` nor `index`: a layout at no address of its own. */ + /** + * A layout at no address of its own: `{ element: , children }`, with + * neither `path` nor `index`, or a `}>` (no + * `path`, or `path=""`) around other ``s. + */ pathless?: boolean; + /** A `` (or a `path=""` with nothing inside): the page at its parent's address. */ + index?: boolean; + /** A ``: an address the file does not spell out, so no route of its own. */ + opaque?: boolean; } /** @@ -500,12 +508,14 @@ interface TableUse { literal?: number; /** The routes around the site, outermost first: where the table is mounted. */ prefix: RoutePart[]; + /** The layouts around the site, outermost first: what the table's routes render inside. */ + layouts: string[]; } interface RouteScan { routes: RouteDeclaration[]; - /** The tables a table names as more of its routes, each with the path parts it sits under. */ - names: Array; + /** The tables a table names as more of its routes, each with the path parts and layouts it sits under. */ + names: Array; /** Where the file hands a table to the router. */ uses: TableUse[]; } @@ -517,6 +527,9 @@ interface RouteScan { */ const ROUTER_CALL = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements|useRoutes)\b/; +/** A router written as an element; the ``s at its top are at `/`. */ +const ROUTER_ELEMENT = /^(?:Browser|Hash|Memory|Native|Static|History|unstable_History)?Router$/; + /** A route list's entry that names more routes: `MainRoutes`, `...authRoutes`, `...routes(user)`. */ const NAMED_ENTRY = /^(?:\.\.\.\s*)?([A-Za-z_$][\w$]*)\s*(\([\s\S]*\))?$/; @@ -532,22 +545,28 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat * * With `objects`, every route object in the file is read. With `table`, only * the literal at that offset is — a table another file hands the router — - * its routes sitting under `table.prefix`, and the identifiers it names as - * more routes come back in `names`. With `uses`, the places the file hands - * a table to the router come back too. A route object whose children render - * something is the layout around them, and the address is theirs when one - * of them claims it: an `index` route, or a `''` path. + * its routes sitting under `table.prefix` and inside `table.layouts`, and the + * identifiers it names as more routes come back in `names`. With `uses`, the + * places the file hands a table to the router come back too. + * + * A route that renders something around others — a route object with + * `children`, a `` with ``s inside — is the layout they + * render inside, and the address is theirs when one of them claims it: an + * `index` route, or a `''` path. A `` that renders nothing only + * groups them; it is a route of its own until one of them claims its address. */ function scanRoutes( source: string, allowJsx: boolean, - options: { objects?: boolean; table?: { at: number; prefix: RoutePart[] }; uses?: boolean } + options: { objects?: boolean; table?: { at: number; prefix: RoutePart[]; layouts?: string[] }; uses?: boolean } ): RouteScan { const scopes: RouteScope[] = []; const names: TableName[] = []; const sites: Array<{ name?: TableName; literal?: number; at: number }> = []; /** Every `` tag: where it is, the props it spreads, its `path` expression. */ const routeTags: Array<{ at: number; spreads: string[]; path?: string }> = []; + /** Where the router itself is written (`createRoutesFromElements(…)`, `…`): its routes start at `/`. */ + const routers: Array<{ start: number; end: number }> = []; const objects = Boolean(options.objects || options.table); const literal = (value: string): string | undefined => { const match = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/.exec(value.trim()); @@ -560,10 +579,13 @@ function scanRoutes( const componentName = (value: string | undefined, jsx: boolean): string | undefined => { if (!value) return undefined; if (!jsx) return /^\s*([A-Z][\w]*)\s*$/.exec(value)?.[1]; - const inner = value.replace(/^\s*\(\s*/, ''); + const inner = value.replace(/^\s*(?:\(\s*)?/, ''); if (!/^<\s*[A-Z]/.test(inner)) return undefined; - const tags = [...inner.matchAll(/<\s*([A-Z][\w]*)\s*(?=[\s/>])/g)].map((m) => m[1]!); - return tags.find((t) => !WRAPPER.test(t)) ?? tags[0]; + const { own, props } = elementTags(inner); + const content = (t: string): boolean => !WRAPPER.test(t) && t !== 'Outlet'; + // Then a page handed to a guard as a prop (`} />`), + // then the innermost wrapper: `` renders the guard. + return own.find(content) ?? props.find(content) ?? [...own].reverse().find((t) => t !== 'Outlet') ?? own[0]; }; // The few characters before `at`, trailing whitespace skipped: enough for the // end-anchored checks below without copying the whole prefix per `/` or `<`. @@ -598,6 +620,12 @@ function scanRoutes( if (!map) return null; return { map: { name: map[1]!, call: false, at: at - before.length + map.index + map[0].indexOf(map[1]!) } }; }; + const routesFromElements = (at: number): boolean => { + let j = at - 1; + while (j >= 0 && /\s/.test(source[j]!)) j--; + if (source[j] !== 's' && source[j] !== 'n') return false; + return /(?:^|[^\w$.])createRoutesFrom(?:Elements|Children)\s*$/.test(source.slice(Math.max(0, at - 40), at)); + }; const settle = (call: { map?: TableName }, open: number, close: number): void => { const argAt = trivia(open + 1); if (argAt >= close) return; @@ -653,6 +681,7 @@ function scanRoutes( const close = ch === '{' ? '}' : ch === '[' ? ']' : ch === '(' ? ')' : undefined; if (!close) return at + 1; const call = ch === '(' ? routerCall(at) : null; + const router = ch === '(' && routesFromElements(at); let i = at + 1; const fields = new Map(); while ((i = trivia(i)) < source.length && source[i] !== close) { @@ -706,6 +735,7 @@ function scanRoutes( } } if (call && i < source.length) settle(call, at, i); + if (router) routers.push({ start: at, end: i }); return i < source.length ? i + 1 : i; } function jsx(at: number): number { @@ -723,42 +753,76 @@ function scanRoutes( continue; } i = trivia(i + attr[0].length); - if (source[i] !== '=') continue; + // A bare attribute is `true`: `}/>`. + if (source[i] !== '=') { + attrs.set(attr[0], '{true}'); + continue; + } i = trivia(i + 1); const start = i; i = unit(i); attrs.set(attr[0], source.slice(start, i)); } let scope: RouteScope | undefined; + // A `` with no address of its own, settled once its children are read. + let addressless: { scope: RouteScope; tags: number; page: boolean } | undefined; + // `…`: the routes at its top are at `/`. + const router = ROUTER_ELEMENT.test(tag[1]!) ? { start: at, end: source.length } : undefined; + if (router) routers.push(router); if (tag[1] === 'Route' && i < source.length) { - const path = literal(attrs.get('path') ?? ''); + const pathText = attrs.get('path'); const expression = (name: string) => attrs.get(name)?.replace(/^\{([\s\S]*)\}$/, '$1'); + // `path="team"`, or the same string in braces: `path={"team"}`. + const path = literal(pathText ?? '') ?? literal(expression('path') ?? ''); routeTags.push({ at, spreads, path: expression('path')?.trim() }); - const component = componentName(expression('component'), false) ?? componentName(expression('element'), true); + // `element={}` renders the route inside it and nothing of its own. + const element = componentName(expression('element'), true)?.replace(/^Outlet$/, '') || undefined; + const component = componentName(expression('component'), false) ?? element; const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(expression('lazy') ?? '')?.[1]; - if (path) { - scope = { part: { lit: path }, at, start: at, end: source.length, component, lazy, jsx: true }; - scopes.push(scope); + const route: RouteScope = { part: { lit: path ?? '' }, at, start: at, end: source.length, component, lazy, jsx: true }; + if (path) scope = route; + else if (pathText === undefined || path === '') { + // `}/>` is the page at its parent's address. + if (/^\{\s*true\s*\}$/.test(attrs.get('index') ?? '')) { + if (component || lazy) scope = { ...route, index: true }; + } else { + // Otherwise it is a layout at no address of its own when routes sit + // inside it (`}>`), and `path=""` with + // an `element` and nothing inside is the page at its parent's + // address. Version 5's `` is a + // catch-all, which no address names. + addressless = { scope: route, tags: routeTags.length, page: path === '' && Boolean(element || lazy) }; + } + } else { + // `path={paths.home}`: an address the file does not spell out. + scope = { ...route, opaque: true }; } + if (scope) scopes.push(scope); } - if (source.startsWith('/>', i)) { - if (scope) scope.end = i + 2; - return i + 2; - } + const close = (end: number): number => { + if (scope) scope.end = end; + if (router) router.end = end; + if (addressless) { + const { scope: route, tags, page } = addressless; + route.end = end; + if (routeTags.length > tags) { + if (route.component || route.lazy) scopes.push({ ...route, pathless: true }); + } else if (page) scopes.push({ ...route, index: true }); + } + return end; + }; + if (source.startsWith('/>', i)) return close(i + 2); i++; while (i < source.length) { if (source.startsWith('', i + 2); - const after = end < 0 ? source.length : end + 1; - if (scope) scope.end = after; - return after; + return close(end < 0 ? source.length : end + 1); } if (source[i] === '<' && /^<(?:[A-Za-z]|>)/.test(source.slice(i))) i = jsx(i); else if (source[i] === '{') i = unit(i); else i++; } - if (scope) scope.end = i; - return i; + return close(i); } if (options.table) unit(trivia(options.table.at), true); else { @@ -768,11 +832,13 @@ function scanRoutes( // A child route's path is relative to the routes around it: compose each // rendering route's path from the path-bearing scopes that contain it. const prefix = options.table?.prefix ?? []; + const inherited = options.table?.layouts ?? []; const renders = (s: RouteScope): boolean => Boolean(s.component || s.lazy); const holds = (outer: RouteScope, start: number, end: number): boolean => outer.start < start && outer.end >= end; const around = (start: number, end: number): RouteScope[] => scopes.filter((outer) => holds(outer, start, end)).sort((a, b) => a.start - b.start); const shown = (p: RoutePart): string => p.lit ?? `{${p.expr}}`; + const layoutOf = (s: RouteScope): string => s.component ?? LAZY_ROUTE_PREFIX + s.lazy; const partsOf = new Map(); const pathOf = new Map(); for (const scope of scopes) { @@ -780,23 +846,47 @@ function scanRoutes( partsOf.set(scope, parts); pathOf.set(scope, composeRoutePath(parts.map(shown))); } + // The page at its parent's address needs that address written down: a + // route around it whose path the file spells out, or the router itself. At + // the top of a component's own `` it is wherever another route + // mounts the component (`}>`). + const inRouter = (s: RouteScope): boolean => routers.some((r) => r.start < s.start && r.end >= s.end); + const addressed = (s: RouteScope): boolean => { + if (!s.index) return true; + const outer = around(s.start, s.end); + return !outer.some((c) => c.opaque) && (outer.some((c) => Boolean(c.part.lit || c.part.expr)) || inRouter(s)); + }; + const pages = new Set(scopes.filter((s) => !s.pathless && !s.opaque && (s.jsx || renders(s)) && addressed(s))); + const layout = (c: RouteScope): boolean => renders(c) && !c.opaque; const routes: RouteDeclaration[] = []; - for (const scope of scopes) { - if (scope.pathless || (!renders(scope) && !scope.jsx)) continue; + for (const scope of pages) { const path = pathOf.get(scope)!; - // A route object around others is the layout they render inside; when one - // of them claims its address, the page there is that one, not the layout. - if (!scope.jsx && renders(scope) && scopes.some((inner) => - !inner.jsx && !inner.pathless && renders(inner) && holds(scope, inner.start, inner.end) && pathOf.get(inner) === path)) continue; - const layouts = around(scope.start, scope.end) - .filter((c) => !c.jsx && renders(c)) - .map((c) => c.component ?? LAZY_ROUTE_PREFIX + c.lazy); + // A route around others is the layout they render inside; when one of + // them claims its address, the page there is that one — not the layout, + // nor a `` that only groups them. + if (scopes.some((inner) => pages.has(inner) && !inner.jsx === !scope.jsx && renders(inner) && + holds(scope, inner.start, inner.end) && pathOf.get(inner) === path)) continue; + // A route object renders inside the route objects around it; a ``, + // inside the routes around it of either kind. + const layouts = [...inherited, ...around(scope.start, scope.end) + .filter((c) => layout(c) && (scope.jsx || !c.jsx)) + .map(layoutOf)]; routes.push({ path, parts: partsOf.get(scope)!, component: scope.component, lazy: scope.lazy, layouts, at: scope.at }); } + const mount = (at: number): { parts: RoutePart[]; layouts: string[] } => { + const outer = around(at, at); + return { parts: outer.map((c) => c.part), layouts: outer.filter(layout).map(layoutOf) }; + }; return { routes: routes.sort((a, b) => a.at - b.at), - names: names.map((n) => ({ ...n, prefix: [...prefix, ...around(n.at, n.at).map((c) => c.part)] })), - uses: sites.map((s) => ({ name: s.name, literal: s.literal, prefix: around(s.at, s.at).map((c) => c.part) })), + names: names.map((n) => { + const { parts, layouts } = mount(n.at); + return { ...n, prefix: [...prefix, ...parts], layouts: [...inherited, ...layouts] }; + }), + uses: sites.map((s) => { + const { parts, layouts } = mount(s.at); + return { name: s.name, literal: s.literal, prefix: parts, layouts }; + }), }; } @@ -841,6 +931,45 @@ function mapsRouteFields(callback: string, tags: ReadonlyArray<{ spreads: string (t.path !== undefined && (ownPath?.test(t.path) || paths.has(t.path)))); } +/** + * The component tags of a JSX element and of what it nests, outermost first + * (`own`), and apart from them a page handed over in a `component`, + * `element` or `page` attribute (`props`). Any other attribute's elements + * are neither: `}>` + * renders AdminPanel, never Loader. + */ +function elementTags(text: string): { own: string[]; props: string[] } { + const own: string[] = []; + const props: string[] = []; + for (let i = 0; i < text.length; i++) { + if (text[i] !== '<') continue; + const open = /^<\s*([A-Za-z][\w.:-]*)(?=[\s/>])/.exec(text.slice(i)); + if (!open) continue; + if (/^[A-Z]\w*$/.test(open[1]!)) own.push(open[1]!); + // Skip the tag's attributes, expressions and strings whole. + let j = i + open[0].length; + let attr = ''; + while (j < text.length && text[j] !== '>') { + const ch = text[j]!; + const name = /^[A-Za-z_$][\w$:-]*/.exec(text.slice(j, j + 64)); + if (name) { + attr = name[0]; + j += name[0].length; + continue; + } + const end = ch === '{' ? matchBracket(text, j) : ch === '"' || ch === "'" ? skipString(text, j) : j; + if (end < 0) break; + if (ch === '{' && /^(?:component|element|page)$/i.test(attr)) { + for (const m of text.slice(j, end).matchAll(/<\s*([A-Z]\w*)(?=[\s/>])/g)) props.push(m[1]!); + } + if (ch === '{' || ch === '"' || ch === "'") attr = ''; + j = end + 1; + } + i = j; + } + return { own, props }; +} + /** The comma-separated entries of a list's inside, nested brackets kept whole. */ function topLevelEntries(text: string): string[] { const out: string[] = []; @@ -909,8 +1038,11 @@ function routeReferences(route: RouteDeclaration, fromNodeId: string, filePath: * from the item's own fields. From there the import leads to the table, which * is read the way a data router's is, and the tables it names in turn * (`...ApiAuthorizationRoutes`, `children: adminRoutes`) are read under the - * path they sit at. A file that makes a router itself had its route objects - * read when it was extracted; only the tables it names are new here. + * path they sit at — and inside the layouts around them (`}>{DashboardRoutes.map(…)}`) when the + * table is written in the same file. A file that makes a router itself had + * its route objects read when it was extracted; only the tables it names are + * new here. */ function tableRoutes(context: ResolutionContext): FrameworkExtractionResult { const nodes: Node[] = []; @@ -922,14 +1054,17 @@ function tableRoutes(context: ResolutionContext): FrameworkExtractionResult { } } if (consumers.size === 0) return { nodes, references }; - const queue: Array<{ file: string; at: number; prefix: string[] }> = []; + const queue: Array<{ file: string; at: number; prefix: string[]; layouts: string[] }> = []; const queued = new Set(); - const enqueue = (table: { file: string; at: number } | null, prefix: string[]): void => { + // A table renders inside the layouts around the place that names it. A + // route's `layout:` reference is resolved in the route's own file, so they + // go along only when that place is in the table's file. + const enqueue = (table: { file: string; at: number } | null, prefix: string[], from: string, layouts: string[]): void => { if (!table || queued.size >= MAX_TABLES) return; const key = `${table.file}\0${table.at}\0${prefix.join('\0')}`; if (queued.has(key)) return; queued.add(key); - queue.push({ ...table, prefix }); + queue.push({ ...table, prefix, layouts: table.file === from ? layouts : [] }); }; for (const file of [...consumers].sort()) { const source = context.readFile(file); @@ -938,17 +1073,17 @@ function tableRoutes(context: ResolutionContext): FrameworkExtractionResult { const scan = scanRoutes(source, allowsJsx(file), { objects: ROUTER_CALL.test(source), uses: true }); for (const use of scan.uses) { const table = use.literal !== undefined ? { file, at: use.literal } : tableNamed(use.name!, file, context); - enqueue(table, pathValues(use.prefix, file, context)); + enqueue(table, pathValues(use.prefix, file, context), file, use.layouts); } } const made = new Set(); const now = Date.now(); for (let next = queue.shift(); next; next = queue.shift()) { - const { file, at, prefix } = next; + const { file, at, prefix, layouts } = next; const source = context.readFile(file); if (!source) continue; - const scan = scanRoutes(source, allowsJsx(file), { table: { at, prefix: prefix.map((lit) => ({ lit })) } }); - for (const named of scan.names) enqueue(tableNamed(named, file, context), pathValues(named.prefix, file, context)); + const scan = scanRoutes(source, allowsJsx(file), { table: { at, prefix: prefix.map((lit) => ({ lit })), layouts } }); + for (const named of scan.names) enqueue(tableNamed(named, file, context), pathValues(named.prefix, file, context), file, named.layouts); if (ROUTER_CALL.test(source)) continue; const lineAt = makeLineAt(source, 1); for (const route of scan.routes) {