/** * React Router links written through a route-config object (bulletproof-react's * `config/paths.ts`): `` and * `navigate(paths.auth.login.getHref())` read the href the helper returns — * a template's whole-segment `${id}` stays a parameter, a hole glued onto a * segment (`?redirectTo=…`) is dropped. An arrow-function attribute before * `to` (`onMouseEnter={() => …}`) no longer ends the tag early. */ 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-config-href-')); const files: Record = { 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }), 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }), 'src/config/paths.ts': `export const paths = { auth: { login: { path: '/auth/login', getHref: (redirectTo?: string | null | undefined) => \`/auth/login\${redirectTo ? \`?redirectTo=\${encodeURIComponent(redirectTo)}\` : ''}\`, }, }, app: { root: { path: '/app', getHref: () => '/app' }, discussions: { path: 'discussions', getHref: () => '/app/discussions' }, discussion: { path: 'discussions/:discussionId', getHref: (id: string) => \`/app/discussions/\${id}\`, }, }, } as const; `, 'src/app/router.tsx': `import { createBrowserRouter } from 'react-router'; import { paths } from '@/config/paths'; export const router = createBrowserRouter([ { path: paths.auth.login.path, lazy: () => import('./routes/auth/login') }, { path: paths.app.root.path, lazy: () => import('./routes/app/root'), children: [ { path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') }, { path: paths.app.discussion.path, lazy: () => import('./routes/app/discussion') }, ], }, ]); `, 'src/app/routes/auth/login.tsx': `export default function LoginRoute() { return null; } `, 'src/app/routes/app/root.tsx': `export default function AppRoot() { return null; } `, 'src/app/routes/app/discussions.tsx': `export default function DiscussionsRoute() { return null; } `, 'src/app/routes/app/discussion.tsx': `export default function DiscussionRoute() { return null; } `, 'src/features/discussions/discussions-list.tsx': `import { Link } from 'react-router'; import { paths } from '@/config/paths'; export function DiscussionsList({ items, prefetch }: { items: { id: string }[]; prefetch: (id: string) => void }) { return items.map((d) => ( prefetch(d.id)} to={paths.app.discussion.getHref(d.id)}> open )); } `, 'src/lib/auth.tsx': `import { useNavigate } from 'react-router'; import { paths } from '@/config/paths'; export function Logout() { const navigate = useNavigate(); return ; } `, }; 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 }); }); /** `route name` of every navigates edge out of a file. */ function navigatesFrom(file: string): string[] { const ids = cg.getNodesInFile(file).map((n) => n.id); return cg .getOutgoingEdgesFrom(ids) .filter((e) => e.kind === 'navigates') .map((e) => cg.getNode(e.target)!.name) .sort(); } describe('React Router links through a route-config object', () => { it('reads a Link’s `to={paths.x.getHref(id)}`, past an arrow attribute before it', () => { expect(navigatesFrom('src/features/discussions/discussions-list.tsx')).toEqual(['/app/discussions/:discussionId']); }); it('reads `navigate(paths.x.getHref(…))`, dropping a query suffix glued onto the path', () => { expect(navigatesFrom('src/lib/auth.tsx')).toEqual(['/auth/login']); }); });