diff --git a/CHANGELOG.md b/CHANGELOG.md
index 765589afe..e09430d6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -159,6 +159,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A React Router route, or a page loaded with `lazy(() => import(…))`, now links to its page when the folder it loads passes the page on through an import of its own, like `import { UserLayout } from './UserLayout'; export { UserLayout }` or `import Login from './Login'; export default Login`. Before, the folder's `index.ts` was searched for a page declared in it, which it doesn't have, so the route linked nothing. A default export that wraps the page in a call, like `export default withAuth(Page)`, still links neither the wrapper nor a guess. Re-index React projects after upgrading.
- `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it.
- React Router routes are now indexed when the router gets its table from a function through a variable, as in Medusa's admin dashboard, which writes `const routes = getRouteMap({ … })` and then `createBrowserRouter(routes)`. The same goes for `export const routes = buildRoutes()` or `export default buildRoutes()` imported into `useRoutes(routes)`, and for `const routes = await loadRoutes()`. Before, such a variable was read only when it held the table itself, so these apps had no routes: their pages had no route leading to them, and their `navigate('/orders/…')` calls and `` links led nowhere. Routes the function is handed by its caller, like the plugin routes Medusa spreads in, are still not read, and when two functions in one file each keep a `routes` variable, the router reads its own and never the other's, which may be a menu. Re-index React projects after upgrading.
+- A React Router route written as `element: ` or `Component: Outlet`, or whose `lazy` loader hands back `{ Component: Outlet }`, now counts as rendering nothing of its own, as a route with no element does and as `}>` already did. Before, `Outlet` was taken for the route's page and for the layout of the routes inside it, which linked nothing, and a loader returning `{ Component: Outlet }` made the module it loads for its `loader` the layout of the routes inside: in Medusa's admin dashboard, a product's detail page was drawn around its variants' pages. Such a route is still a route at its own address, inside the layouts around it, and a page inside it at the same address still takes the address over. Re-index React projects after upgrading.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
- In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `
Don't have an account? `, still links as before. Re-index React projects after upgrading.
diff --git a/__tests__/react-router-outlet-routes.test.ts b/__tests__/react-router-outlet-routes.test.ts
new file mode 100644
index 000000000..3558e2fb0
--- /dev/null
+++ b/__tests__/react-router-outlet-routes.test.ts
@@ -0,0 +1,206 @@
+/**
+ * A React Router route that renders `` renders the routes inside it
+ * and nothing of its own: a route object with no element does exactly that,
+ * so `element: `, `Component: Outlet` and a `lazy` loader that hands
+ * back `{ Component: Outlet }` (Medusa's `:id` routes, which import their page
+ * module only for its `loader` and breadcrumb) are no page and no layout. JSX
+ * routes already read `element={}` this way.
+ *
+ * Before, a route object's `` was its component: every route inside
+ * it got a `layout:Outlet` reference, which links nothing (the Medusa and
+ * Mercur dashboards each had 81 such failed refs). And a loader returning
+ * `{ Component: Outlet }` read as "can't tell", so the route fell back to the
+ * module the loader imports: its page became the layout of every route
+ * inside, a detail page drawn around its own sibling routes.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+const page = (name: string, extra = ''): string =>
+ `${extra}export function Component() {\n return
;
+}
+
+export function RootErrorBoundary() {
+ return
Error
;
+}
+`,
+ 'src/Nav.tsx': `import { useNavigate } from 'react-router-dom';
+
+export function Nav() {
+ const navigate = useNavigate();
+ const openApprovals = () => navigate('/approvals');
+ const openAgent = (id: string) => navigate(\`/agents/\${id}\`);
+ return ;
+}
+`,
+ 'src/pages/region-list.tsx': page('RegionList'),
+ 'src/pages/region-create.tsx': page('RegionCreate'),
+ 'src/pages/product-list.tsx': page('ProductList'),
+ 'src/pages/product-detail.tsx': page('ProductDetail', 'export async function loader() {\n return null;\n}\n\n'),
+ 'src/pages/variant-detail.tsx': page('VariantDetail'),
+ 'src/pages/order-list.tsx': page('OrderList', 'export async function loader() {\n return null;\n}\n\n'),
+ 'src/pages/order-create.tsx': page('OrderCreate'),
+};
+
+/** `path -> page` for what each route renders, `path ~> layout` for each layout around it; a page is named by its file. */
+function routeBindings(cg: CodeGraph): string[] {
+ return cg.getNodesByKind('route').flatMap((r) => cg.getOutgoingEdges(r.id)
+ .filter((e) => e.kind === 'references')
+ .map((e) => {
+ const target = cg.getNode(e.target)!;
+ const name = target.name === 'Component' ? path.basename(target.filePath, '.tsx') : target.name;
+ return `${r.name} ${(e.metadata as Record | undefined)?.layout ? '~>' : '->'} ${name}`;
+ }))
+ .sort();
+}
+
+describe('react-router: a route that renders renders nothing of its own', () => {
+ let root: string;
+ let cg: CodeGraph;
+ beforeAll(async () => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-outlet-'));
+ 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);
+ }
+ cg = await CodeGraph.init(root, { index: true });
+ }, 60_000);
+ afterAll(() => {
+ cg?.close();
+ if (root) fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('binds each address to its page, inside the layouts that render something', () => {
+ expect(routeBindings(cg)).toEqual([
+ // The `path: ''` route groups its children at `/`, where Layout stays the page.
+ '/ -> Layout',
+ // A route that renders only is still an address: no page, its layouts kept.
+ '/agents/:agentId ~> Shell',
+ '/approvals ~> Shell',
+ '/orders -> order-list',
+ '/orders ~> Shell',
+ '/orders/new -> order-create',
+ '/orders/new ~> Shell',
+ '/products -> product-list',
+ '/products ~> Shell',
+ '/products/:id -> product-detail',
+ '/products/:id ~> Shell',
+ // Not inside product-detail: its route hands React Router `Component: Outlet`.
+ '/products/:id/variants/:variant_id -> variant-detail',
+ '/products/:id/variants/:variant_id ~> Shell',
+ '/projects/:projectId -> Project',
+ '/projects/:projectId ~> Layout',
+ '/regions -> region-list',
+ '/regions ~> Shell',
+ '/regions/create -> region-create',
+ '/regions/create ~> Shell',
+ ]);
+ });
+
+ it('keeps an address no route inside claims, and the navigation that leads there', () => {
+ const routes = cg.getNodesByKind('route').map((r) => r.name).sort();
+ // `/products/:id`, `/regions` and `/orders` stay single routes: the page inside claims each
+ // address. So does `/`: the `path: ''` route under Layout is no route of its own.
+ expect(routes).toEqual([
+ '/', '/agents/:agentId', '/approvals', '/orders', '/orders/new', '/products', '/products/:id',
+ '/products/:id/variants/:variant_id', '/projects/:projectId', '/regions', '/regions/create',
+ ]);
+ const navigations = cg.getNodesByKind('route').flatMap((r) => cg.getIncomingEdges(r.id)
+ .filter((e) => e.kind === 'navigates')
+ .map((e) => `${cg.getNode(e.source)?.name} -> ${r.name}`))
+ .sort();
+ expect(navigations).toEqual(['openAgent -> /agents/:agentId', 'openApprovals -> /approvals']);
+ });
+
+ it('leaves no route reference to Outlet behind', () => {
+ const db = (cg as any).db.db;
+ const refs = db.prepare(
+ "SELECT reference_name AS name FROM unresolved_refs WHERE from_node_id LIKE 'route:%' AND reference_name IN ('Outlet', 'layout:Outlet')"
+ ).all() as Array<{ name: string }>;
+ expect(refs).toEqual([]);
+ });
+});
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index 18ae63f9a..4201748e3 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -38,7 +38,7 @@ guessed.
|---|---|---|---|---|
| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
-| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). A table a function returns, handed over through a variable (`const routes = getRouteMap({ … })` in a class's `render()`, then `createBrowserRouter(routes)`; trap 13): Medusa's admin dashboard (0 → 221 routes, each bound to its page, 613 layout edges, 92 navigations newly linked: 60 `navigate()` calls and 32 ``s; all 821 edges an AST oracle on the TypeScript compiler expects, the other 13 from three `:id` loaders that return `Component: Outlet`, read as their module's page), Mercur's admin panel (0 → 216; 775 of 775, 5 the same), serenedb's serene-ui (`buildRoutes = () => [...appRoutes, ...loaderRoutes]`: 0 → 4), 22 control repos byte-identical, and a late-file sync equal to a fresh index on both dashboards. JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical |
+| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). A table a function returns, handed over through a variable (`const routes = getRouteMap({ … })` in a class's `render()`, then `createBrowserRouter(routes)`; trap 13): Medusa's admin dashboard (0 → 221 routes, each bound to its page, 613 layout edges, 92 navigations newly linked: 60 `navigate()` calls and 32 ``s; all 821 edges an AST oracle on the TypeScript compiler expects, and no others), Mercur's admin panel (0 → 216; 775 of 775), serenedb's serene-ui (`buildRoutes = () => [...appRoutes, ...loaderRoutes]`: 0 → 4), 22 control repos byte-identical, and a late-file sync equal to a fresh index on both dashboards. Route objects that render `` (`element: `, `Component: Outlet`, a `lazy` loader handing back `{ Component: Outlet }`; trap 14): both dashboards and mercurjs/vendor-panel drop every `layout:Outlet` reference they made (93, 106 and 87 rows, none of which linked anything) and the 13, 5 and 5 layout edges that drew a product's detail page around its variants' pages; React Router's error-boundaries example's `/` becomes its `Layout` page instead of React Router's own `Outlet`; 21 other repos byte-identical, and 2,677 surveyed router files change only by dropping 40 such references. JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical |
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) |
@@ -310,7 +310,15 @@ Each of these cost real debugging time; they are not hypothetical.
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.
+ every long element; and read `` as rendering nothing of its own,
+ in a route object too (`element: `, `Component: Outlet`, a `lazy`
+ loader handing back `{ Component: Outlet }`), as a route with no element
+ renders one: taking it for the page made every route inside a
+ `layout:Outlet` that links nothing, and a loader returning it fell back to
+ the module it loads for its `loader`, drawing Medusa's product page around
+ its variants. Such an object is still a route at its own address, inside
+ the layouts around it, unless a page inside claims the address or it sits
+ at its parent's (`path: ''`), where it only groups its children.
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
diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts
index 1c82e378c..ee6712d22 100644
--- a/src/resolution/frameworks/react.ts
+++ b/src/resolution/frameworks/react.ts
@@ -245,6 +245,8 @@ export const reactResolver: FrameworkResolver = {
const LAZY_ROUTE_PREFIX = 'lazy-import:';
const ROUTE_PARTS_PREFIX = 'route-parts:';
const LAYOUT_PREFIX = 'layout:';
+/** React Router's ``: what a route with no element of its own renders, the routes inside it. */
+const OUTLET = 'Outlet';
/**
* The export a lazy route's loader picks, named the way Vue Router and
@@ -914,6 +916,14 @@ interface RouteScope {
lazy?: string;
/** A `` element: a route even with nothing to render (a `` that only groups others). */
jsx?: boolean;
+ /**
+ * A route object that renders React Router's `` — `element:
+ * `, `Component: Outlet`, a loader handing back `{ Component:
+ * Outlet }` — as a route with no element does: a route at its address with
+ * nothing of its own, like a `` that only groups others, and no
+ * layout around the routes inside it.
+ */
+ outlet?: boolean;
/**
* A layout at no address of its own: `{ element: , children }`, with
* neither `path` nor `index`, or a `}>` (no
@@ -1026,8 +1036,10 @@ function scanRoutes(
// then the innermost wrapper: `` renders the guard.
return own.find(content) ?? props.find(content) ?? [...own].reverse().find((t) => t !== 'Outlet') ?? own[0];
};
+ // The component an `element` shows, `Outlet` included.
+ const elementTag = (value: string | undefined): string | undefined => componentName(value, true);
// The page an `element` shows: `element={}` renders the route inside it and nothing of its own.
- const elementPage = (value: string | undefined): string | undefined => componentName(value, true)?.replace(/^Outlet$/, '') || undefined;
+ const elementPage = (value: string | undefined): string | undefined => elementTag(value)?.replace(/^Outlet$/, '') || undefined;
// The few characters before `at`, trailing whitespace skipped: enough for the
// end-anchored checks below without copying the whole prefix per `/` or `<`.
const tokenBefore = (at: number): string => {
@@ -1167,12 +1179,18 @@ function scanRoutes(
const pathField = fields.get('path');
const path = pathField && literal(pathField.value);
const expr = pathField && path === undefined && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(pathField.value) ? pathField.value : undefined;
- const component = componentName(fields.get('element')?.value, true)
- ?? componentName(fields.get('Component')?.value, false);
- const lazy = lazyRouteReference(fields.get('lazy')?.value, elementPage);
+ const shown = elementTag(fields.get('element')?.value) ?? componentName(fields.get('Component')?.value, false);
+ const loaded = lazyRouteReference(fields.get('lazy')?.value, elementTag);
+ // A route with no element renders an ``, so one written with
+ // it renders nothing of its own either: a route at its address, and no
+ // layout around the routes inside it. At its parent's address (`path:
+ // ''`) it only groups them, the parent's page staying the one there.
+ const outlet = (shown ?? loaded) === OUTLET && path !== '';
+ const component = shown === OUTLET ? undefined : shown;
+ const lazy = loaded === OUTLET ? undefined : loaded;
const index = fields.get('index');
if (pathField && (path !== undefined || expr)) {
- scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, start: at, end: i, component, lazy });
+ scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, start: at, end: i, component, lazy, outlet });
} else if (!pathField && index?.value === 'true') {
// `{ index: true, element: }` is the page at its parent's address.
scopes.push({ part: { lit: '' }, at: index.at, start: at, end: i, component, lazy });
@@ -1223,7 +1241,9 @@ function scanRoutes(
routeTags.push({ at, spreads, path: expression('path')?.trim() });
const element = elementPage(expression('element'));
const component = componentName(expression('component'), false) ?? element;
- const lazy = lazyRouteReference(expression('lazy'), elementPage);
+ const loaded = lazyRouteReference(expression('lazy'), elementTag);
+ // A `` is a route with nothing of its own to render already.
+ const lazy = loaded === OUTLET ? undefined : loaded;
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 === '') {
@@ -1301,7 +1321,7 @@ function scanRoutes(
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 pages = new Set(scopes.filter((s) => !s.pathless && !s.opaque && (s.jsx || s.outlet || renders(s)) && addressed(s)));
const layout = (c: RouteScope): boolean => renders(c) && !c.opaque;
const routes: RouteDeclaration[] = [];
for (const scope of pages) {
@@ -1481,14 +1501,17 @@ const FIRST_IMPORT = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/;
* - A component of the route's own file it returns is named as written:
* `return { loader, Component: SettingsPage }`.
*
- * Undefined when it renders nothing, handing over only a `loader`. A loader
- * this does not read, such as a helper's (`lazyPage(() => import('./x'))`)
- * or `.then(convert)`, loads the module its first import names, as before.
+ * `Outlet` when it hands back React Router's (`return { Component: Outlet,
+ * loader }`, which Medusa's `:id` routes write to load a breadcrumb): the
+ * routes inside it render in its place. Undefined when it renders nothing,
+ * handing over only a `loader`. A loader this does not read, such as a
+ * helper's (`lazyPage(() => import('./x'))`) or `.then(convert)`, loads the
+ * module its first import names, as before.
*/
-function lazyRouteReference(loader: string | undefined, elementPage: (jsx: string) => string | undefined): string | undefined {
+function lazyRouteReference(loader: string | undefined, elementTag: (jsx: string) => string | undefined): string | undefined {
if (!loader) return undefined;
const code = stripCommentsForRegex(loader, 'typescript').trim();
- const rendered = code.startsWith('{') ? lazyPropertiesRender(code) : routeRender(loaderValue(code, new Map()), elementPage);
+ const rendered = code.startsWith('{') ? lazyPropertiesRender(code) : routeRender(loaderValue(code, new Map()), elementTag);
if (rendered === null) return undefined;
if (rendered !== undefined) return rendered;
const spec = FIRST_IMPORT.exec(code)?.[1];
@@ -1497,9 +1520,10 @@ function lazyRouteReference(loader: string | undefined, elementPage: (jsx: strin
/**
* What route properties a loader resolves to render: the reference for its
- * page, null for none, undefined for properties this does not read.
+ * page, `Outlet` for React Router's, null for none, undefined for properties
+ * this does not read.
*/
-function routeRender(value: Loaded | null, elementPage: (jsx: string) => string | undefined): string | null | undefined {
+function routeRender(value: Loaded | null, elementTag: (jsx: string) => string | undefined): string | null | undefined {
if (value?.kind === 'module') return LAZY_ROUTE_PREFIX + value.spec;
if (value?.kind !== 'object') return undefined;
const { props, spreads } = objectEntries(value.text);
@@ -1507,14 +1531,17 @@ function routeRender(value: Loaded | null, elementPage: (jsx: string) => string
const element = props.get('element');
const shown = component ?? element;
if (shown !== undefined) {
- const named = component !== undefined ? pageReference(valueOf(component, value.scope)) : undefined;
+ const held = component !== undefined ? valueOf(component, value.scope) : null;
+ const named = pageReference(held);
if (named) return named;
// The one export the loader imported that it shows, past a guard of the
// route file's own: ``, `() =>
// } />`, `withAuth(Page)`.
const loaded = loadedExports(shown, value.scope);
if (loaded.length === 1) return pageReference(loaded[0]!);
- const tag = component === undefined && loaded.length === 0 ? elementPage(element!) : undefined;
+ const tag = component === undefined && loaded.length === 0 ? elementTag(element!) : undefined;
+ // `Component: Outlet`, `element: `: the routes inside render in its place.
+ if ((held?.kind === 'name' && held.name === OUTLET) || tag === OUTLET) return OUTLET;
return tag ? pageReference(nameValue(tag, value.scope)) : undefined;
}
// `{ ...module }`: the module's exports are the route's properties.