fix(react-router): a lazy route follows a barrel that re-exports an import (#2558)

A lazily loaded folder whose index passes on a page it imports linked
nothing: DigiShop.Frontend's `src/layout/index.ts` does `import { UserLayout }
from './UserLayout'; export { UserLayout }`, and its routes load
`await import('@/layout')`. exportedComponent, the barrel walk behind lazy
routes, React.lazy values and lazy layouts (#2436, #2452, #2480), read the
export clause as naming a declaration in the barrel, which has none: the
name is an import binding. `import Login from './Login'; export default
Login` and `export { Page as Component }` of an imported Page failed the
same way, so all 27 lazy route refs in DigiShop's App.tsx stayed failed.

A binding the module exports but does not declare is now followed through
the module's import of it, a default import as `default` and a named one by
the name it imports, with each file read added to `reads` so lazyModules
still redraws the route. A namespace import or a package's binding links
nothing. `export default withAuth(Page)` hands over a call, whose first
identifier ownExport reads; that was harmless while only same-file
declarations counted, so a default is followed only when the statement is
the binding itself (exportsBinding), or the route would link the HOC.

#2451 (open) adds the same step to the general import walk; #2420 (open)
carried it in a lazyRouteComponent rewrite that exportedComponent superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-14 17:55:40 +00:00
committed by GitHub
parent 5da4a41d92
commit e30a2c164d
4 changed files with 254 additions and 6 deletions
+1
View File
@@ -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.
@@ -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);
});
+1 -1
View File
@@ -38,7 +38,7 @@ guessed.
|---|---|---|---|---|
| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 `<Redirect>`), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… <Route {...rest}>)` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `<Route path="/dashboard">` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `<Route element={<DashboardLayout/>}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `<Route>` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels, but its routes are not extracted: `getRouteMap()` returns the table), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project |
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 `<Redirect>`), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… <Route {...rest}>)` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `<Route path="/dashboard">` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `<Route element={<DashboardLayout/>}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `<Route>` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels, but its routes are not extracted: `getRouteMap()` returns the table), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. Known limit, shared with #2436's `export *` barrels: a page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) is linked by a fresh index but not by a sync that adds it later, because the route's failed reference waits under the barrel's module key (`module:pages`) |
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) |
+23 -5
View File
@@ -484,8 +484,9 @@ const MAX_REEXPORT_HOPS = 4;
* Page`, `export function Component`, `export { Page as Component }` — or one
* it forwards from the module that declares it, as a barrel `index.ts` does:
* `export { default } from './page'`, `export { Page as Component } from
* './page'`, or `export * from './page'`, which forwards every name but the
* default. As in JavaScript, a name the file exports itself hides one an
* './page'`, `export * from './page'`, which forwards every name but the
* default, or an import it exports again, `import { Page } from './page';
* export { Page }`. As in JavaScript, a name the file exports itself hides one an
* `export *` would forward, and a name two `export *` modules both forward is
* exported by neither. `reads` collects every file the lookup reads.
*/
@@ -498,15 +499,26 @@ function exportedComponent(
): string | null {
if (visited.has(file) || visited.size > MAX_REEXPORT_HOPS) return null;
reads?.add(file);
const local = ownExport(stripCommentsForRegex(context.readFile(file) ?? '', 'typescript'), name);
if (local) return context.getNodesInFile(file).find((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind))?.id ?? null;
const language = scriptLanguage(file);
const reExports = context.getReExports?.(file, language) ?? [];
const through = new Set(visited).add(file);
const forwarded = (source: string, exported: string): string | null => {
const target = resolveImportPath(source, file, language, context);
return target ? exportedComponent(target, exported, context, through, reads) : null;
};
const code = stripCommentsForRegex(context.readFile(file) ?? '', 'typescript');
const local = ownExport(code, name);
if (local) {
const declared = context.getNodesInFile(file).find((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind));
if (declared) return declared.id;
// A name the module imports and exports again — `import { Page } from
// './page'; export { Page }`, `import Page from './Page'; export default
// Page` — is the page that import names. `export default withAuth(Page)`
// hands over no binding of its own.
if (name === 'default' && !exportsBinding(code, local)) return null;
const imported = context.getImportMappings(file, language).find((m) => m.localName === local);
return imported && !imported.isNamespace ? forwarded(imported.source, imported.isDefault ? 'default' : imported.exportedName) : null;
}
const reExports = context.getReExports?.(file, language) ?? [];
for (const re of reExports) {
if (re.kind === 'named' && re.exportedName === name) return forwarded(re.source, re.originalName);
}
@@ -536,6 +548,12 @@ function ownExport(source: string, name: string): string | null {
return extractLocalExportAliases(source).find((a) => a.exportedName === name)?.localName ?? null;
}
/** True when the module's default export is the binding `local` itself: `export default Page;`, `export { Page as default }`. */
function exportsBinding(source: string, local: string): boolean {
return new RegExp(String.raw`(?:^|;)\s*export\s+default\s+${local.replace(/\$/g, '\\$')}\s*(?:;|$)`, 'm').test(source) ||
extractLocalExportAliases(source).some((a) => a.exportedName === 'default' && a.localName === local);
}
/**
* A component `filePath` holds in a value of its own, for a route there that
* renders it: the component of the module a lazy declaration loads (`lazy(()