Files
codegraph/__tests__/sync-nested-barrel-route.test.ts
Colby Mchenry 9b237093a3 fix(sync): a lazy route relinks a page that appears below its barrel (#2560)
A lazy React Router route whose module is a barrel, and whose page sits in a
folder below the barrel's, stayed unlinked after `codegraph sync` added the
page or gave it its export, though a fresh index linked it. In
DigiShop.Frontend, `@/pages` is `src/pages/index.ts`, which imports `./Home`,
`src/pages/Home/index.tsx`, and exports it again. While the page is missing,
the route's reference fails and waits under the key of the module it names,
`module:pages` (#2422). The retry looks failed references up by the keys of
the changed files, and a file in `pages/Home/` gives only `module:Home` and
its own stem. The limit, which the `export *` barrels of #2436 share, was
recorded with #2558.

After the failed-ref retry, which makes the barrel's import edge into an
added file, sync now walks up the import edges from each file it added, or
gave a definition, to the barrels that pass it on: `export … from`, or an
import exported again (`import { X } from './x'; export { X }`, `export
default X`), as deep as the resolver chases a name. It re-opens, for the
sweep, the failed references waiting under those barrels' keys.

Only keys a failed reference waits under are looked up, in one DISTINCT
over the failed rows parked under module keys, so a sync with none reads
nothing, and a body-only edit seeds nothing. The ImportReader that the
added-files reopen (#2512) uses is shared, and gains exportedBindings,
behind a regex pre-check, since most importers export nothing.
defaultExportBinding moves to alias-binding.ts for this walk and for
react.ts's exportsBinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:31:51 +00:00

133 lines
5.1 KiB
TypeScript

/**
* A lazy route whose module is a barrel, and whose page sits in a folder below
* the barrel's (DigiShop.Frontend: `@/pages` is `src/pages/index.ts`, which
* imports `./Home`, which is `src/pages/Home/index.tsx`). While the page is
* missing the route's reference fails, and it waits under the key of the
* module it names, `module:pages`. A file in `src/pages/Home/` gives its own
* folder's and stem's keys, so `codegraph sync` never revisited the route when
* the page appeared, though a fresh index links it.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
type Files = Record<string, string | null>;
const ROUTER: Files = {
'package.json': JSON.stringify({ name: 'shop', 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: '/',
lazy: async () => {
const { HomePage } = await import('@/pages');
return { Component: HomePage };
},
},
{
path: '/cart',
lazy: async () => {
const { CartPage } = await import('@/shop');
return { Component: CartPage };
},
},
{ path: '/admin', lazy: () => import('@/admin') },
]);
`,
// \`export … from\` into a folder below.
'src/pages/index.ts': "export * from './home';\n",
'src/pages/home/index.ts': "export { HomePage } from './HomePage';\n",
// An import the barrel exports again.
'src/shop/index.ts': "import { CartPage } from './cart';\n\nexport { CartPage };\n",
// Two barrels below the one the route loads.
'src/admin/index.ts': "export * from './dashboard';\n",
'src/admin/dashboard/index.ts': "export * from './panel';\n",
'src/admin/dashboard/panel/index.ts': "export * from './Dashboard';\n",
};
const PAGES: Files = {
'src/pages/home/HomePage.tsx': 'export function HomePage() {\n return null;\n}\n',
'src/shop/cart/index.tsx': 'export function CartPage() {\n return null;\n}\n',
'src/admin/dashboard/panel/Dashboard.tsx': 'export function Component() {\n return null;\n}\n',
};
const LINKED = {
'/': ['HomePage@src/pages/home/HomePage.tsx'],
'/cart': ['CartPage@src/shop/cart/index.tsx'],
'/admin': ['Component@src/admin/dashboard/panel/Dashboard.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)) {
const file = path.join(dir, rel);
if (content === null) {
fs.rmSync(file, { force: true });
continue;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 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-sync-nested-barrel-'));
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()])
);
const NONE = { '/': [], '/cart': [], '/admin': [] };
describe('sync, for a lazy route whose page sits below its barrel', () => {
it('links each page once it appears', async () => {
const { dir, graph } = await indexed(ROUTER);
expect(routeLinks(graph)).toEqual(NONE);
write(dir, PAGES);
expect((await graph.sync()).filesAdded).toBe(3);
expect(routeLinks(graph)).toEqual(LINKED);
expect(routeLinks((await indexed({ ...ROUTER, ...PAGES })).graph)).toEqual(LINKED);
}, 60_000);
it('links each page once an edit gives it the export', async () => {
const placeholders = Object.fromEntries(Object.keys(PAGES).map((file) => [file, 'export const placeholder = 1;\n']));
const { dir, graph } = await indexed({ ...ROUTER, ...placeholders });
expect(routeLinks(graph)).toEqual(NONE);
write(dir, PAGES);
expect((await graph.sync()).filesModified).toBe(3);
expect(routeLinks(graph)).toEqual(LINKED);
}, 60_000);
it('links a page again after it is deleted and comes back', async () => {
const { dir, graph } = await indexed({ ...ROUTER, ...PAGES });
expect(routeLinks(graph)).toEqual(LINKED);
write(dir, Object.fromEntries(Object.keys(PAGES).map((file) => [file, null])));
await graph.sync();
expect(routeLinks(graph)).toEqual(NONE);
write(dir, PAGES);
await graph.sync();
expect(routeLinks(graph)).toEqual(LINKED);
}, 60_000);
});