Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Link>` 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: <Outlet />` 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 `<Route element={<Outlet />}>` 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 `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <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 `{/* <Progress /> */}`, or an error message like react-router's "You cannot render a <Router> inside another <Router>" or shadcn's "useCarousel must be used within a <Carousel />". 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 `<p>Don't have an account? <Link to="/signup">`, still links as before. Re-index React projects after upgrading.
Expand Down
206 changes: 206 additions & 0 deletions __tests__/react-router-outlet-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* A React Router route that renders `<Outlet />` renders the routes inside it
* and nothing of its own: a route object with no element does exactly that,
* so `element: <Outlet />`, `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={<Outlet />}` this way.
*
* Before, a route object's `<Outlet />` 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 <div>${name}</div>;\n}\n`;

const FILES: Record<string, string> = {
'package.json': JSON.stringify({ name: 'admin', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
'src/Shell.tsx': `import { Outlet } from 'react-router-dom';\n\nexport function Shell() {\n return <main><Outlet /></main>;\n}\n`,
'src/router.tsx': `import { createBrowserRouter, Outlet } from 'react-router-dom';
import { Shell } from './Shell';

export const router = createBrowserRouter([
{
element: <Shell />,
children: [
{
path: '/regions',
element: <Outlet />,
children: [
{ path: '', lazy: () => import('./pages/region-list') },
{ path: 'create', lazy: () => import('./pages/region-create') },
],
},
{
path: '/products',
Component: Outlet,
children: [{ index: true, lazy: () => import('./pages/product-list') }],
},
{
path: '/products/:id',
lazy: async () => {
const { loader } = await import('./pages/product-detail');
return { Component: Outlet, loader };
},
children: [
{ path: '', lazy: () => import('./pages/product-detail') },
{ path: 'variants/:variant_id', lazy: () => import('./pages/variant-detail') },
],
},
{
path: '/orders',
lazy: async () => {
const { loader } = await import('./pages/order-list');
return { element: <Outlet />, loader };
},
children: [
{ index: true, lazy: () => import('./pages/order-list') },
{ path: 'new', lazy: () => import('./pages/order-create') },
],
},
// Addresses the shell draws itself: routes of their own, with no page.
{ path: '/approvals', element: <Outlet /> },
{ path: '/agents/:agentId', Component: Outlet },
],
},
]);
`,
// React Router's error-boundaries example: a `path: ''` route that renders an
// <Outlet /> only to hold an errorElement, at its parent's address.
'src/errors/router.tsx': `import { createBrowserRouter, Outlet } from 'react-router-dom';
import { Layout, Project, RootErrorBoundary } from './routes';

export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: '',
element: <Outlet />,
errorElement: <RootErrorBoundary />,
children: [{ path: 'projects/:projectId', element: <Project /> }],
},
],
},
]);
`,
'src/errors/routes.tsx': `import { Outlet } from 'react-router-dom';

export function Layout() {
return <div><Outlet /></div>;
}

export function Project() {
return <div>Project</div>;
}

export function RootErrorBoundary() {
return <div>Error</div>;
}
`,
'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 <nav><button onClick={openApprovals} /><button onClick={() => openAgent('a')} /></nav>;
}
`,
'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<string, unknown> | undefined)?.layout ? '~>' : '->'} ${name}`;
}))
.sort();
}

describe('react-router: a route that renders <Outlet /> 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: ''` <Outlet /> route groups its children at `/`, where Layout stays the page.
'/ -> Layout',
// A route that renders only <Outlet /> 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: ''` <Outlet /> 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([]);
});
});
Loading