Files
codegraph/__tests__/react-router-config-href.test.ts
Colby MchenryandClaude Opus 5.5 1f64e15886 fix(react-router): read links through a route-config object's getHref (#2187)
bulletproof-react writes every link as `<Link to={paths.app.discussion.getHref(id)}>`
and every redirect as `navigate(paths.auth.login.getHref(...))`, so none of its
navigation reached a route. The href reader now follows the config object to
where it is declared (through imports and tsconfig aliases), takes the string or
template the helper returns, keeps a whole-segment `${id}` as a parameter and
drops a hole glued onto a segment (a `?redirectTo=` suffix).

The Link tag pattern also stopped at the `>` of an arrow attribute written
before `to` (`onMouseEnter={() => prefetch(id)}`), which hid those links.

bulletproof-react: 0 -> 11 navigates edges; jira_clone, ecommerce-react,
react-redux-realworld, create-t3-turbo and excalidraw unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:17:06 +00:00

115 lines
4.2 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.
/**
* React Router links written through a route-config object (bulletproof-react's
* `config/paths.ts`): `<Link to={paths.app.discussion.getHref(id)}>` 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<string, string> = {
'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) => (
<Link onMouseEnter={() => prefetch(d.id)} to={paths.app.discussion.getHref(d.id)}>
open
</Link>
));
}
`,
'src/lib/auth.tsx': `import { useNavigate } from 'react-router';
import { paths } from '@/config/paths';
export function Logout() {
const navigate = useNavigate();
return <button onClick={() => navigate(paths.auth.login.getHref(location.pathname))}>out</button>;
}
`,
};
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']);
});
});