mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
16344af8ff
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>
274 lines
10 KiB
TypeScript
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);
|
|
});
|