Files
codegraph/__tests__/react-router-nested-lazy.test.ts
Colby Mchenry dd7064698c fix(react-router): read route tables another file hands the router (#2400)
A route table kept in a file of its own produced no routes: the ASP.NET
Core React template's AppRoutes array, which App.js maps into
<Route {...rest}>, a routes array imported into useRoutes(routes) or
createBrowserRouter(routes), and codedthemes' one-object-per-file
[MainRoutes, LoginRoutes]. Route objects were read only from a file that
itself names a data router, so these apps had no routes and their
navigate()/<Link to> navigation reached nothing.

The table's own file cannot tell it from a menu's { path, element } list,
so the evidence is the file that hands it over: a useRoutes /
create*Router argument, or a .map whose callback renders a <Route> from
the item's own fields ({...route}, {...rest}, path={route.path}). A new
FrameworkResolver.crossFileNodes hook keeps those routes, reconciled in
runPostExtract on every index and sync (removal-only syncs included):
new ones are inserted with pending references, ones no longer wanted are
removed with their incoming resolution edges resurrected, unchanged ones
keep their edges. The React pass starts from the files that import React
Router, follows the import to the table and the tables it names
(...ApiAuthorizationRoutes, children: x), each under its mount, and owns
its routes by id (route:<file>:<line>:table:<path>).

Route objects (tables and data routers alike): an index: true route is
the page at its parent's address; a route object around others is their
layout (a references edge marked layout: true, as Angular's), and is not
a page of its own where a child claims its address. A route renders past
a *Wrapper or *Route guard, and through a same-file
lazy(() => import(...)) to the module it loads, never a same-named
component in another app. useRoutes joins the file-level gate.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:27:44 +00:00

108 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A data router's nested, lazy, constant-named routes (bulletproof-react):
* - a child's `path` is relative to its parent's — `discussions` under `/app`
* is `/app/discussions`;
* - `lazy: () => import('./routes/x')` renders the module's default export;
* - `path: paths.app.root.path` reads the constant object where it is
* declared — here through a monorepo app's own tsconfig `@/*` alias, which
* now resolves from the tsconfig nearest the importing file;
* - `element: (<ProtectedRoute><AppRoot /></ProtectedRoute>)` renders AppRoot.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-nested-'));
const app = (rel: string) => `apps/web/${rel}`;
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'mono', private: true }),
[app('package.json')]: JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }),
[app('tsconfig.json')]: JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
[app('src/config/paths.ts')]: `export const paths = {
home: { path: '/', getHref: () => '/' },
app: {
root: { path: '/app', getHref: () => '/app' },
discussions: { path: 'discussions', getHref: () => '/app/discussions' },
},
} as const;
`,
[app('src/app/router.tsx')]: `import { createBrowserRouter } from 'react-router';
import { paths } from '@/config/paths';
import { ProtectedRoute } from '@/lib/auth';
import AppRoot from './routes/app/root';
export const router = createBrowserRouter([
{ path: paths.home.path, lazy: () => import('./routes/landing') },
{
path: paths.app.root.path,
element: (
<ProtectedRoute>
<AppRoot />
</ProtectedRoute>
),
children: [
{ path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') },
{ path: 'settings', element: <Settings /> },
],
},
]);
`,
[app('src/lib/auth.tsx')]: `export function ProtectedRoute({ children }: { children: unknown }) { return children; }
`,
[app('src/app/routes/landing.tsx')]: `const LandingRoute = () => null;
export default LandingRoute;
`,
[app('src/app/routes/app/root.tsx')]: `export default function AppRoot() { return null; }
`,
[app('src/app/routes/app/discussions.tsx')]: `export default function DiscussionsRoute() { return null; }
`,
};
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 });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const bindings = (routeName: string, layout: boolean): string[] => {
const route = cg.getNodesByKind('route').find((r) => r.name === routeName);
if (!route) return [];
return cg.getOutgoingEdges(route.id)
.filter((e) => e.kind === 'references' && Boolean((e.metadata as Record<string, unknown> | undefined)?.layout) === layout)
.map((e) => cg.getNode(e.target)!.name);
};
const renders = (routeName: string): string[] => bindings(routeName, false);
describe('nested, lazy and constant React Router routes', () => {
it('compose their paths and render their modules', () => {
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
expect(names).toEqual(['/', '/app', '/app/discussions', '/app/settings']);
expect(renders('/')).toEqual(['LandingRoute']);
expect(renders('/app')).toEqual(['AppRoot']);
expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']);
});
it('a child route renders inside its parent’s component, the layout around it', () => {
expect(bindings('/app/discussions', true)).toEqual(['AppRoot']);
expect(bindings('/', true)).toEqual([]);
});
it('resolves an app’s own tsconfig alias', () => {
const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id);
const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath);
expect(imported).toContain('apps/web/src/config/paths.ts');
expect(imported).toContain('apps/web/src/lib/auth.tsx');
});
});