Files
codegraph/__tests__/react-router-nested-lazy.test.ts
Colby MchenryandClaude Opus 5.5 37bcd5c0ae fix(react-router): nested, lazy and constant routes; per-app tsconfig aliases (#2165)
- Route tables compose nested paths: a child's `path` is relative to the
  path-bearing routes around it (`children` of a data router, nested
  `<Route>` elements), absolute ones reset. `lazy: () => import('./x')`
  (object and JSX) renders the module's default export (`Component` export
  as fallback) via a claimed `lazy-import:` ref. `path: paths.app.root.path`
  keeps its parts on the node's signature and is named in postExtract from
  the constant's object literal (found through the file's import). The
  route keeps its extracted id; `isReactRouterRoute` accepts it. An element
  wrapped in parentheses or a guard (`<ProtectedRoute>`, `<Suspense>`,
  `*Provider`, `*Guard`) renders the first element inside.
- Import aliases: the tsconfig / jsconfig nearest the importing file that
  declares `paths` is tried before the root's, so each monorepo app resolves
  its own `@/*` / `~/*`.

A/B (edges removed / added): bulletproof-react -43/+367 (0 → 9 named,
linked routes; cross-app imports fixed), react-native-reusables -103/+193,
kit -184/+218, trpc -45/+71 (examples' `~/…` now their own), create-t3-turbo
-1/+13; excalidraw, halo, obytes, realworld byte-identical. #1348's nested
fixtures now expect composed paths (`/dashboard/settings`, `/data/prefs`).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:34:11 +00:00

100 lines
4.0 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 renders = (routeName: string): string[] => {
const route = cg.getNodesByKind('route').find((r) => r.name === routeName);
if (!route) return [];
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name);
};
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('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');
});
});