Files
codegraph/__tests__/react-router-lazy-barrel.test.ts
Colby Mchenry 16344af8ff fix(react-router): a lazy route links the page a barrel module forwards (#2436)
A data router's `lazy: () => import('@/pages/invocation_page')` loads the
folder's `index.ts`. When that file only forwards the page, with
`export * from './invocation_page'`, `export { default } from './page'` or
`export { Page as Component } from './page'`, the route linked nothing: the
module was read for an export of its own and none was found. The lookup now
follows the module's re-exports a few hops to the file that declares the
page, the way JavaScript resolves them (`export *` never forwards the
default, a name two `export *` modules both forward is exported by
neither), and reads a module's own `export { Page as Component }` clause.
`React.lazy` values in a route file, which go through the same lookup,
follow such barrels too.

On luci-go's milo/ui, 51 of 91 failed lazy route refs now link; the rest
load a file under `src/build/` (not indexed), export only a `loader` or a
`handle`, or pick a named export in an async loader. Sync links a page
added or edited behind its barrel the way a fresh index does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:25:59 +00:00

274 lines
10 KiB
TypeScript

/**
* A lazily loaded route module that is a barrel (luci-go's milo/ui):
* `lazy: () => import('@/test_verdict/pages/invocation_page')` loads
* `invocation_page/index.ts`, whose `export * from './invocation_page'`
* forwards the `Component` that `invocation_page.tsx` declares. The route
* renders that component; before, the barrel was read for an export of its
* own, found none, and the route linked nothing. (The fixture's folder is
* `builds/`: a `build/` folder is skipped as build output.)
*/
import { describe, it, expect, afterAll, afterEach, 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-lazy-barrel-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'ui', private: true, dependencies: { react: '^18', 'react-router': '^7' } }),
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
'src/router.tsx': `import { lazy } from 'react';
import { createBrowserRouter } from 'react-router';
const Settings = lazy(() => import('@/settings'));
export const router = createBrowserRouter([
{
path: '/builds/:id',
lazy: () => import('@/builds/build_page'),
children: [{ path: 'overview', lazy: () => import('@/builds/build_page/overview_tab') }],
},
{ path: '/builders', lazy: () => import('@/builds/pages') },
{ path: '/fleet', lazy: () => import('@/fleet/home_page') },
{ path: '/login', lazy: () => import('@/auth/login_page') },
{ path: '/graph', lazy: () => import('@/chronicle/graph_view') },
{ path: '/settings', element: <Settings /> },
{ path: '/redirect', lazy: () => import('@/core/redirection_loader') },
{ path: '/clash', lazy: () => import('@/clash') },
{ path: '/loop', lazy: () => import('@/loop/a') },
{ path: '/no-default', lazy: () => import('@/legacy') },
]);
`,
// `export *` forwards the `Component` its module declares.
'src/builds/build_page/index.ts': `export * from './build_page';
`,
'src/builds/build_page/build_page.tsx': `export const BuildPage = () => null;
export function Component() {
return <BuildPage />;
}
`,
'src/builds/build_page/overview_tab/index.ts': `export * from './overview_tab';
`,
'src/builds/build_page/overview_tab/overview_tab.tsx': `export function Component() {
return null;
}
`,
// A barrel of barrels.
'src/builds/pages/index.ts': `export * from './builder_list_page';
`,
'src/builds/pages/builder_list_page/index.ts': `export * from './builder_list_page';
`,
'src/builds/pages/builder_list_page/builder_list_page.tsx': `export function BuilderListPage() {
return null;
}
export const Component = BuilderListPage;
`,
// A named re-export, renamed to the name React Router reads.
'src/fleet/home_page/index.ts': `export { HomePage as Component } from './home_page';
`,
'src/fleet/home_page/home_page.tsx': `export function HomePage() {
return null;
}
`,
// The default export, forwarded.
'src/auth/login_page/index.ts': `export { default } from './login_page';
`,
'src/auth/login_page/login_page.tsx': `export default function LoginPage() {
return null;
}
`,
// An export clause in the module itself.
'src/chronicle/graph_view.tsx': `function GraphView() {
return null;
}
export { GraphView as Component };
`,
// `React.lazy` reads the default export, through a barrel too.
'src/settings/index.ts': `export { default } from './settings_page';
`,
'src/settings/settings_page.tsx': `export default function SettingsPage() {
return null;
}
`,
// A module that only redirects renders nothing.
'src/core/redirection_loader.ts': `export function redirectionLoader() {
return null;
}
export const loader = redirectionLoader;
`,
// Two modules forward a `Component` through `export *`: JavaScript exports neither.
'src/clash/index.ts': `export * from './first';
export * from './second';
`,
'src/clash/first.tsx': `export function Component() {
return null;
}
`,
'src/clash/second.tsx': `export function Component() {
return null;
}
`,
// Barrels that forward each other.
'src/loop/a.ts': `export * from './b';
`,
'src/loop/b.ts': `export * from './a';
`,
// `export *` forwards every name but the default.
'src/legacy/index.ts': `export * from './legacy_page';
`,
'src/legacy/legacy_page.tsx': `export default function LegacyPage() {
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 });
}, 60_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** What a route's `references` edges reach, as `name@file`: what it renders, or the layouts around it. */
const bindings = (routeName: string, layout: boolean): string[] => {
const route = cg.getNodesByKind('route').find((r) => r.name === routeName);
if (!route) return [`no route ${routeName}`];
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)!)
.map((n) => `${n.name}@${n.filePath}`);
};
const renders = (routeName: string): string[] => bindings(routeName, false);
describe('a lazy route module that is a barrel', () => {
it('renders the Component an `export *` barrel forwards', () => {
expect(renders('/builds/:id')).toEqual(['Component@src/builds/build_page/build_page.tsx']);
expect(renders('/builds/:id/overview')).toEqual(['Component@src/builds/build_page/overview_tab/overview_tab.tsx']);
});
it('follows a barrel that forwards another barrel', () => {
expect(renders('/builders')).toEqual(['Component@src/builds/pages/builder_list_page/builder_list_page.tsx']);
});
it('renders inside the layout a barrel forwards', () => {
expect(bindings('/builds/:id/overview', true)).toEqual(['Component@src/builds/build_page/build_page.tsx']);
});
it('follows a named re-export to what it renames', () => {
expect(renders('/fleet')).toEqual(['HomePage@src/fleet/home_page/home_page.tsx']);
expect(renders('/login')).toEqual(['LoginPage@src/auth/login_page/login_page.tsx']);
});
it('reads an export clause in the module itself', () => {
expect(renders('/graph')).toEqual(['GraphView@src/chronicle/graph_view.tsx']);
});
it('follows the barrel a React.lazy value loads', () => {
expect(renders('/settings')).toEqual(['SettingsPage@src/settings/settings_page.tsx']);
});
it('links nothing a barrel does not forward', () => {
// A loader-only module, a name two `export *` modules both forward, a
// cycle, and a default export behind `export *`.
expect(renders('/redirect')).toEqual([]);
expect(renders('/clash')).toEqual([]);
expect(renders('/loop')).toEqual([]);
expect(renders('/no-default')).toEqual([]);
});
});
describe('sync, for a lazy route module that is a barrel', () => {
type Files = Record<string, string>;
const ROUTER: Files = {
'package.json': JSON.stringify({ name: 'ui', private: true, dependencies: { react: '^18', 'react-router': '^7' } }),
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
'src/router.tsx': `import { createBrowserRouter } from 'react-router';
export const router = createBrowserRouter([
{ path: '/reports', lazy: () => import('@/reports/report_list') },
{ path: '/reports/:id', lazy: () => import('@/reports/report_page') },
]);
`,
'src/reports/report_list/index.ts': `export { ReportList as Component } from './report_list';
`,
'src/reports/report_page/index.ts': `export * from './report_page';
`,
};
const PAGES: Files = {
'src/reports/report_list/report_list.tsx': `export function ReportList() {
return null;
}
`,
'src/reports/report_page/report_page.tsx': `export function Component() {
return null;
}
`,
};
const LINKED = {
'/reports': ['ReportList@src/reports/report_list/report_list.tsx'],
'/reports/:id': ['Component@src/reports/report_page/report_page.tsx'],
};
let dirs: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
graphs = [];
dirs = [];
});
const write = (dir: string, files: Files): void => {
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
}
};
/** A project of `files`, indexed from scratch in a folder of its own. */
const indexed = async (files: Files): Promise<{ dir: string; graph: CodeGraph }> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-lazy-barrel-sync-'));
dirs.push(dir);
write(dir, files);
const graph = await CodeGraph.init(dir, { index: true });
graphs.push(graph);
return { dir, graph };
};
/** Each route's `references` edges, as `name@file`. */
const routeLinks = (graph: CodeGraph): Record<string, string[]> => Object.fromEntries(
graph.getNodesByKind('route').map((route) => [route.name, graph.getOutgoingEdges(route.id)
.filter((e) => e.kind === 'references')
.map((e) => graph.getNode(e.target)!)
.map((n) => `${n.name}@${n.filePath}`)
.sort()])
);
it('links a page that appears behind its barrel later', async () => {
const { dir, graph } = await indexed(ROUTER);
expect(routeLinks(graph)).toEqual({ '/reports': [], '/reports/:id': [] });
write(dir, PAGES);
expect((await graph.sync()).filesAdded).toBe(2);
expect(routeLinks(graph)).toEqual(LINKED);
expect(routeLinks((await indexed({ ...ROUTER, ...PAGES })).graph)).toEqual(LINKED);
}, 60_000);
it('links a page once an edit gives it the Component its barrel forwards', async () => {
const { dir, graph } = await indexed({ ...ROUTER, ...PAGES, 'src/reports/report_page/report_page.tsx': 'export const placeholder = 1;\n' });
expect(routeLinks(graph)['/reports/:id']).toEqual([]);
write(dir, { 'src/reports/report_page/report_page.tsx': PAGES['src/reports/report_page/report_page.tsx']! });
expect((await graph.sync()).filesModified).toBe(1);
expect(routeLinks(graph)).toEqual(LINKED);
}, 60_000);
});