Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a call is now matched to the package or variable it is actually made on. Before, a call through a package, like `klog.Infof("Log using Infof")` or `digest.Digest(dig)` inside `&distribution.Descriptor{Digest: …}`, could link to a project method of the same name when the name appeared again on the line, and in `klog.Error(err.Error())` the `err.Error()` call was taken for a call into `klog`. A parameter, receiver or local variable named like an import, like etcd's `jwt, err := newTokenProviderJWT(…)` or the `suite` receiver in a testify test suite, now counts as that variable, so calls on it link to the methods of the type it is declared as, and to nothing when that type comes from outside your project. Re-index Go projects after upgrading.
- React Router routes written as JSX now read `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `<Suspense fallback={<Loader />}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading.
- 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.
- 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
273 changes: 273 additions & 0 deletions __tests__/react-router-lazy-barrel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/**
* 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);
});
Loading