Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading.
- A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: <AgeGate><FireworksPage /></AgeGate>`. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading.
- A React Router route, or a page loaded with `lazy(() => import(…))`, now links to its page when the folder it loads passes the page on through an import of its own, like `import { UserLayout } from './UserLayout'; export { UserLayout }` or `import Login from './Login'; export default Login`. Before, the folder's `index.ts` was searched for a page declared in it, which it doesn't have, so the route linked nothing. A default export that wraps the page in a call, like `export default withAuth(Page)`, still links neither the wrapper nor a guess. Re-index React projects after upgrading.
- `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
Expand Down
132 changes: 132 additions & 0 deletions __tests__/sync-nested-barrel-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* 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);
});
Loading