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 @@ -137,6 +137,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- 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.
- 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
229 changes: 229 additions & 0 deletions __tests__/react-router-barrel-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* A lazily loaded module that passes on a page it imports
* (Arshia76/DigiShop.Frontend): `src/layout/index.ts` does `import {
* UserLayout } from './UserLayout'; export { UserLayout }`, so
* `lazy: async () => { const { UserLayout } = await import('@/layout');
* return { Component: UserLayout }; }` renders `UserLayout`. The barrel
* declares no `UserLayout` of its own, and before, its export clause was read
* as naming one, so the route linked nothing.
*/
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;

const PKG = JSON.stringify({ name: 'shop', private: true, dependencies: { react: '^18', 'react-router': '^7', '@mui/material': '^6' } });
const TSCONFIG = JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } });

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-barrel-imports-'));
const files: Record<string, string> = {
'package.json': PKG,
'tsconfig.json': TSCONFIG,
'src/router.tsx': `import { lazy } from 'react';
import { createBrowserRouter } from 'react-router';
const Help = lazy(() => import('@/help'));
export const router = createBrowserRouter([
{
path: '/',
lazy: async () => {
const { UserLayout } = await import('@/layout');
return { Component: UserLayout };
},
children: [
{
index: true,
lazy: async () => {
const { HomePage } = await import('@/pages');
return { Component: HomePage };
},
},
{ path: 'login', lazy: () => import('@/auth/login') },
{ path: 'settings', lazy: () => import('@/settings') },
{ path: 'help', element: <Help /> },
{
path: 'tabs',
lazy: async () => {
const { Tabs } = await import('@/tabs');
return { Component: Tabs };
},
},
{
path: 'button',
lazy: async () => {
const { Button } = await import('@/ui');
return { Component: Button };
},
},
{ path: 'account', lazy: () => import('@/account') },
],
},
{
path: '/admin',
lazy: async () => {
const { AdminLayout } = await import('@/layout');
return { Component: AdminLayout };
},
},
]);
`,
// DigiShop's barrel: imports, then one export clause, no semicolons.
'src/layout/index.ts': `import { AdminLayout } from './AdminLayout'
import { UserLayout } from './UserLayout'
export { AdminLayout, UserLayout }
`,
'src/layout/AdminLayout.tsx': 'export function AdminLayout() {\n return null;\n}\n',
'src/layout/UserLayout.tsx': 'export const UserLayout = () => null;\n',
// A barrel that imports from another barrel.
'src/pages/index.ts': "import { HomePage } from './home';\n\nexport { HomePage };\n",
'src/pages/home/index.ts': "export * from './HomePage';\n",
'src/pages/home/HomePage.tsx': 'export function HomePage() {\n return null;\n}\n',
// The default export is an imported binding.
'src/auth/login/index.ts': "import Login from './Login';\n\nexport default Login;\n",
'src/auth/login/Login.tsx': 'export default function Login() {\n return null;\n}\n',
// Renamed to the name React Router reads.
'src/settings/index.ts': "import { SettingsPage } from './SettingsPage';\n\nexport { SettingsPage as Component };\n",
'src/settings/SettingsPage.tsx': 'export function SettingsPage() {\n return null;\n}\n',
// `React.lazy` reads the default, here an imported binding renamed to it.
'src/help/index.ts': "import HelpPage from './HelpPage';\n\nexport { HelpPage as default };\n",
'src/help/HelpPage.tsx': 'export default function HelpPage() {\n return null;\n}\n',
// A namespace object is no component.
'src/tabs/index.ts': "import * as Tabs from './tabs';\n\nexport { Tabs };\n",
'src/tabs/tabs.tsx': 'export function First() {\n return null;\n}\n',
// A package's component is not the project's to link.
'src/ui/index.ts': "import { Button } from '@mui/material';\n\nexport { Button };\n",
// The default export is a call: no binding the module imports is the page.
'src/account/index.ts': "import withAuth from '@/hoc/withAuth';\nimport { AccountPage } from './AccountPage';\n\nexport default withAuth(AccountPage);\n",
'src/account/AccountPage.tsx': 'export function AccountPage() {\n return null;\n}\n',
'src/hoc/withAuth.ts': 'export default function withAuth(Component: unknown) {\n return Component;\n}\n',
};
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 lazily loaded module that passes on a page it imports', () => {
it('renders the page an export clause of imports passes on', () => {
expect(renders('/admin')).toEqual(['AdminLayout@src/layout/AdminLayout.tsx']);
expect(bindings('/login', true)).toEqual(['UserLayout@src/layout/UserLayout.tsx']);
});

it('follows the import to another barrel', () => {
expect(renders('/')).toEqual(['HomePage@src/pages/home/HomePage.tsx']);
});

it('follows a default export that is an imported binding', () => {
expect(renders('/login')).toEqual(['Login@src/auth/login/Login.tsx']);
});

it('follows an imported page the module renames to Component', () => {
expect(renders('/settings')).toEqual(['SettingsPage@src/settings/SettingsPage.tsx']);
});

it('follows the default a React.lazy value loads', () => {
expect(renders('/help')).toEqual(['HelpPage@src/help/HelpPage.tsx']);
});

it('links nothing for a namespace or a package it passes on', () => {
expect(renders('/tabs')).toEqual([]);
expect(renders('/button')).toEqual([]);
});

it('links no wrapper a default export calls', () => {
expect(renders('/account')).toEqual([]);
});
});

describe('sync, for a module that passes on a page it imports', () => {
type Files = Record<string, string>;
const ROUTER: Files = {
'package.json': PKG,
'tsconfig.json': TSCONFIG,
'src/router.tsx': `import { createBrowserRouter } from 'react-router';
export const router = createBrowserRouter([
{ path: '/login', lazy: () => import('@/auth/login') },
]);
`,
'src/auth/login/index.ts': "import Login from './Login';\n\nexport default Login;\n",
};
const PAGE: Files = { 'src/auth/login/Login.tsx': 'export default function Login() {\n return null;\n}\n' };

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-barrel-imports-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 the page once the module it imports appears', async () => {
const { dir, graph } = await indexed(ROUTER);
expect(routeLinks(graph)).toEqual({ '/login': [] });
write(dir, PAGE);
expect((await graph.sync()).filesAdded).toBe(1);
expect(routeLinks(graph)).toEqual({ '/login': ['Login@src/auth/login/Login.tsx'] });
expect(routeLinks((await indexed({ ...ROUTER, ...PAGE })).graph)).toEqual({ '/login': ['Login@src/auth/login/Login.tsx'] });
}, 60_000);

it('follows the imported page when its default export moves to another component', async () => {
const { dir, graph } = await indexed({ ...ROUTER, ...PAGE });
expect(routeLinks(graph)).toEqual({ '/login': ['Login@src/auth/login/Login.tsx'] });
// The old component stays, so the route's edge would follow it through the re-index.
const edit = { 'src/auth/login/Login.tsx': 'export function Login() {\n return null;\n}\n\nexport default function SignIn() {\n return null;\n}\n' };
write(dir, edit);
await graph.sync();
expect(routeLinks(graph)).toEqual({ '/login': ['SignIn@src/auth/login/Login.tsx'] });
expect(routeLinks((await indexed({ ...ROUTER, ...edit })).graph)).toEqual({ '/login': ['SignIn@src/auth/login/Login.tsx'] });
}, 60_000);
});
Loading