From e30a2c164dd401886a083b8b25f89080ced92730 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 17:55:40 +0000 Subject: [PATCH 1/4] 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 --- CHANGELOG.md | 1 + __tests__/react-router-barrel-imports.test.ts | 229 ++++++++++++++++++ docs/design/framework-coverage.md | 2 +- src/resolution/frameworks/react.ts | 28 ++- 4 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 __tests__/react-router-barrel-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 141cf103db..7292b5ede8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `. 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 ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((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. diff --git a/__tests__/react-router-barrel-imports.test.ts b/__tests__/react-router-barrel-imports.test.ts new file mode 100644 index 0000000000..2acd28d1d6 --- /dev/null +++ b/__tests__/react-router-barrel-imports.test.ts @@ -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 = { + '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: }, + { + 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 | 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; + 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 => 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); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index ce773f35ac..26a83f4d23 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -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 ``), 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(… )` 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 `` 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 `}>` (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 `` (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 ``), 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(… )` 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 `` 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 `}>` (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 `` (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) | diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 57736357bb..4d92df28e6 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -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(() From 9b237093a37f9d28e4e9ffd80067080ceab87b5f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 19:31:51 +0000 Subject: [PATCH 2/4] fix(sync): a lazy route relinks a page that appears below its barrel (#2560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + __tests__/sync-nested-barrel-route.test.ts | 132 +++++++++++++++++++++ docs/design/framework-coverage.md | 9 +- src/db/queries.ts | 12 ++ src/index.ts | 36 ++++-- src/resolution/alias-binding.ts | 11 ++ src/resolution/frameworks/react.ts | 5 +- src/resolution/module-importers.ts | 89 +++++++++++++- 8 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 __tests__/sync-nested-barrel-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7292b5ede8..4392254985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `. 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 ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((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. diff --git a/__tests__/sync-nested-barrel-route.test.ts b/__tests__/sync-nested-barrel-route.test.ts new file mode 100644 index 0000000000..a9b3e95145 --- /dev/null +++ b/__tests__/sync-nested-barrel-route.test.ts @@ -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; + +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 => 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); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 26a83f4d23..e7ef8dd499 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -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 ``), 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(… )` 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 `` 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 `}>` (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 `` (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`) | +| 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 ``), 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(… )` 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 `` 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 `}>` (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 `` (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. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before | | 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) | @@ -344,6 +344,13 @@ Each of these cost real debugging time; they are not hypothetical. route's reference back for its resolution sweep. The same holds for the JSX a component renders: a sync that adds a component redraws the `jsx-render` edges, since a tag in a file it never touched may name it. + A route whose reference FAILED waits under the key of the module it names + (`module:pages` for `@/pages`), and a page in a folder below that barrel + (`pages/Home/index.tsx`) gives only its own folder's and stem's keys. So a + sync that adds a file, or gives one a definition, walks up the import + edges to the barrels that pass it on and re-opens what waits under their + keys (`reopenReferencesThroughBarrels`, after the failed-ref retry made the + barrel's import edge into the added file). 18. **A lazy loader is code, and its page is what it hands over.** A React Router `lazy` resolves to the route's properties, so `const { TestTab } = await import('./tabs'); return { Component: TestTab }` renders `TestTab`, diff --git a/src/db/queries.ts b/src/db/queries.ts index 009aef8c6c..6421eec360 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4232,6 +4232,18 @@ export class QueryBuilder { })); } + /** + * The keys failed references wait for a module under (`module:pages`), each + * once. Reads only the failed rows parked under such keys: a range of + * idx_unresolved_failed_tail, or the whole of + * idx_unresolved_failed_module_name. + */ + getFailedModuleKeys(): string[] { + return (this.db + .prepare(`SELECT DISTINCT name_tail AS key FROM unresolved_refs WHERE status = 'failed' AND name_tail GLOB 'module:*'`) + .all() as Array<{ key: string }>).map((row) => row.key); + } + /** * Put failed refs back in the pending set, for the next resolution pass — * the sync's orphan sweep — to try again. Returns the number re-opened. diff --git a/src/index.ts b/src/index.ts index bd9e11a55a..1f50d46ce7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ import { ResolutionResult, } from './resolution'; import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer'; -import { createImportReader, reopenReferencesThroughAddedFiles } from './resolution/module-importers'; +import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels } from './resolution/module-importers'; import { GraphTraverser, GraphQueryManager } from './graph'; import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks'; import { ContextBuilder, createContextBuilder } from './context'; @@ -1112,18 +1112,40 @@ export class CodeGraph { // it bound by name in its absence, or failed, goes back to pending // for the sweep below, which binds it through the module as a fresh // index does. (The removal half runs before removed files are deleted.) - if (result.addedFilePaths && result.addedFilePaths.length > 0) { + const importReader = createImportReader(this.projectRoot); + const added = (result.addedFilePaths ?? []).flatMap((filePath) => { + const file = this.queries.getFileByPath(filePath); + return file ? [{ path: filePath, language: file.language }] : []; + }); + if (added.length > 0) { const tAdded = Date.now(); - const added = result.addedFilePaths.flatMap((filePath) => { - const file = this.queries.getFileByPath(filePath); - return file ? [{ path: filePath, language: file.language }] : []; - }); const reopened = reopenReferencesThroughAddedFiles( - this.queries, createImportReader(this.projectRoot), added, edgesBeforeResolution, + this.queries, importReader, added, edgesBeforeResolution, (specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language) ); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`); } + + // A failed reference that waits for a module through a barrel — + // React Router's `import:@/pages#HomePage`, parked under + // `module:pages` — waits for a file below the barrel too + // (`pages/Home/index.tsx`), whose own keys name its folder and + // stem. Those parked under the keys of the barrels that pass on a + // file this sync adds, or one whose definitions it changed, go + // back to pending for the sweep; a body-only edit satisfies none. + const tBarrels = Date.now(); + const delta = new Set(result.definitionDelta ?? []); + const seeds = new Map(added.map((file) => [file.path, file.language])); + const modified = result.changedFilePaths.filter((filePath) => !seeds.has(filePath)); + if (delta.size > 0 && modified.length > 0) { + for (const node of this.queries.getNodesByFiles(modified)) { + if (delta.has(node.name)) seeds.set(node.filePath, node.language); + } + } + const throughBarrels = reopenReferencesThroughBarrels( + this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language })) + ); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-barrel-modules: ${Date.now() - tBarrels}ms (${throughBarrels} refs re-opened)`); } else { // No git info — use batched resolution to avoid OOM const unresolvedCount = this.queries.getUnresolvedReferencesCount(); diff --git a/src/resolution/alias-binding.ts b/src/resolution/alias-binding.ts index 3251abff05..1af7958e8e 100644 --- a/src/resolution/alias-binding.ts +++ b/src/resolution/alias-binding.ts @@ -148,3 +148,14 @@ export function extractLocalExportAliases(content: string): Array<{ exportedName } return out; } + +/** + * The binding a module's default export is as a whole: `export default + * Page;`, `export { Page as default }`. Null for a declaration or an + * expression: `export default withAuth(Page)` hands over a call, whose first + * identifier is no binding of the module's. + */ +export function defaultExportBinding(content: string): string | null { + return /(?:^|;)\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*(?:;|$)/m.exec(content)?.[1] ?? + extractLocalExportAliases(content).find((a) => a.exportedName === 'default')?.localName ?? null; +} diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 4d92df28e6..95486c5769 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -9,7 +9,7 @@ import { Language, Node } from '../../types'; import { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; import { resolveImportPath } from '../import-resolver'; -import { extractLocalExportAliases } from '../alias-binding'; +import { defaultExportBinding, extractLocalExportAliases } from '../alias-binding'; import { stripCommentsForRegex } from '../strip-comments'; import { makeLineAt } from '../synth-utils'; import { isTestPath } from '../../search/query-utils'; @@ -550,8 +550,7 @@ function ownExport(source: string, name: string): string | 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); + return defaultExportBinding(source) === local; } /** diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 4dec64f3bb..96f963f576 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -24,6 +24,8 @@ import type { Edge, Language, UnresolvedReference } from '../types'; import type { ImportMapping, ReExport } from './types'; import { extractImportMappings, extractReExports, pythonModulePath, REEXPORT_MAX_DEPTH } from './import-resolver'; import { resurrectRefFromDroppedEdge } from './resurrect-ref'; +import { defaultExportBinding, extractLocalExportAliases } from './alias-binding'; +import { moduleReferenceKeys } from '../db/reference-tail'; /** * Languages whose import bindings name what their references are written as: @@ -42,6 +44,9 @@ const BINDING_LANGUAGES = new Set([ */ const reExportDepth = (language: Language): number => (language === 'python' ? 3 : REEXPORT_MAX_DEPTH); +/** An export clause, a default export, or CommonJS exports: a module that can hand over a binding it imports. */ +const EXPORTS_A_BINDING = /\bexport\s*\{|\bexport\s+default\s|\bexports\b/; + /** A Python star import, read the way the resolver reads one. */ const PYTHON_STAR_IMPORT = /^\s*from\s+([\w.]+)\s+import\s+\*/gm; @@ -75,10 +80,16 @@ export interface ImportReader { reExports(file: string, language: Language): ReExport[]; /** The modules a Python file star-imports, which have no import mapping. */ starImports(file: string, language: Language): string[]; + /** + * The bindings an ES module's own exports hand over as they are: each local + * an export clause names (`export { Page }`), and its default export when + * that is a binding (`export default Page;`). + */ + exportedBindings(file: string, language: Language): ReadonlySet; } export function createImportReader(rootDir: string): ImportReader { - const memo = new Map(); + const memo = new Map }>(); const read = (file: string, language: Language) => { let entry = memo.get(file); if (!entry) { @@ -89,6 +100,7 @@ export function createImportReader(rootDir: string): ImportReader { // Gone or unreadable: it imports nothing. } entry = { + content, mappings: content ? extractImportMappings(file, content, language) : [], reExports: content ? extractReExports(content, language) : [], starImports: language === 'python' ? [...content.matchAll(PYTHON_STAR_IMPORT)].map((m) => m[1]!) : [], @@ -101,6 +113,21 @@ export function createImportReader(rootDir: string): ImportReader { mappings: (file, language) => read(file, language).mappings, reExports: (file, language) => read(file, language).reExports, starImports: (file, language) => read(file, language).starImports, + exportedBindings: (file, language) => { + const entry = read(file, language); + if (!entry.exported) { + // Most importers export nothing they import; a commented-out clause + // read as one only costs a key lookup. + const exported = new Set(); + if (EXPORTS_A_BINDING.test(entry.content)) { + for (const alias of extractLocalExportAliases(entry.content)) exported.add(alias.localName); + const binding = defaultExportBinding(entry.content); + if (binding) exported.add(binding); + } + entry.exported = exported; + } + return entry.exported; + }, }; } @@ -152,6 +179,66 @@ export function reopenReferencesThroughAddedFiles( reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed); } +/** The languages a barrel a lazy route reads through is written in: the ES-module family. */ +const BARREL_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); + +/** + * Re-open, for the sync's sweep, the failed references that wait for a module + * through a barrel when a file below the barrel appears or gains a + * definition. React Router's `import:@/pages#HomePage` waits under the key of + * the module it names, `module:pages`. When `pages/index.ts` passes on + * `pages/Home/index.tsx`, that file is what the reference waits for, but its + * own keys name its folder and stem (`module:Home`), never the barrel's. + * + * The barrels are the modules that pass one of `files` on, through `export … + * from` or an import they export again (`import { HomePage } from './Home'; + * export { HomePage }`), followed up as deep as the resolver chases a name. + * Only keys a failed reference waits under are looked up, so a sync with none + * reads nothing. Call after the failed-ref retry: an added file's importers + * reach it through the import edges that retry made. Returns the number + * re-opened. + */ +export function reopenReferencesThroughBarrels( + queries: QueryBuilder, + reader: ImportReader, + files: ReadonlyArray<{ path: string; language: Language }>, +): number { + let level = files.filter((file) => BARREL_LANGUAGES.has(file.language)); + if (level.length === 0) return 0; + const waiting = new Set(queries.getFailedModuleKeys()); + if (waiting.size === 0) return 0; + const keys = new Set(); + const seen = new Set(level.map((file) => file.path)); + for (let depth = 1; level.length > 0 && depth <= REEXPORT_MAX_DEPTH; depth++) { + const next: Array<{ path: string; language: Language }> = []; + for (const module of level) { + for (const i of queries.getImportsInto(`file:${module.path}`, module.path)) { + if (i.refName === null || seen.has(i.filePath) || !BARREL_LANGUAGES.has(i.language)) continue; + if (!passesOn(i.filePath, i.language, i.refName, reader)) continue; + seen.add(i.filePath); + for (const key of moduleReferenceKeys(i.filePath)) if (waiting.has(key)) keys.add(key); + next.push({ path: i.filePath, language: i.language }); + } + } + level = next; + } + if (keys.size === 0) return 0; + const parked = queries.getRetryableFailedReferences([...keys]).flatMap((ref) => (ref.rowId === undefined ? [] : [ref.rowId])); + return queries.reopenFailedReferences(parked); +} + +/** + * Whether `importer` passes on what it imports from `source`: an `export … + * from` of it, or a binding it imports from it and exports again — the forms + * a lazy route's lookup follows through a barrel (`exportedComponent`). + */ +function passesOn(importer: string, language: Language, source: string, reader: ImportReader): boolean { + if (reader.reExports(importer, language).some((rex) => rex.source === source)) return true; + const exported = reader.exportedBindings(importer, language); + return exported.size > 0 && + reader.mappings(importer, language).some((m) => m.source === source && !m.isNamespace && exported.has(m.localName)); +} + /** An import's module edge into a file that an added file now outranks. */ interface OutrankedImport { edge: Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }; From d0bb9be51e78dafa62a29f24076372faad4608da Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 20:39:08 +0000 Subject: [PATCH 3/4] fix(react): a component a file declares itself as a value is what its JSX renders (#2420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PascalCase name that a .tsx/.jsx file declares as a module-level value is that declaration wherever the file uses it: a JSX tag, a route's element, a layout or a TSX type. Its initializer decides what it renders, in order: - the component of the module its lazy loader loads, or the export the loader picks, in either branch of a conditional; - the function it wraps (`observer(function X() {…})`, `forwardRef(Inner) as {…}`); - else the declaration itself. Before, the lookup by kind skipped the value and took a lone same-named component anywhere, often in another app of the repository. - Pattern 1 asks declaredComponent about every PascalCase reference, not only route references. layoutComponent and the jsx-render synthesizer's jsxChild ask it too. - valueLoader reads a value's loader with loaderValue (#2480). - defaultExportedName reads `export default observer(Login)`. - lazyValueModules records the modules a file's lazy values load as synthesis inputs, so a sync that changes one of them redraws the tag. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/jsx-child-disambiguation.test.ts | 295 +++++++++++++++++++ __tests__/react-router.test.ts | 51 ++++ __tests__/sync-lazy-route-value.test.ts | 3 +- docs/design/framework-coverage.md | 25 +- src/resolution/callback-synthesizer.ts | 38 ++- src/resolution/frameworks/react.ts | 321 ++++++++++++++++++--- 7 files changed, 681 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4392254985..9d00b7b23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading. - In React and Vue, a tag for a component the file imports from a package, like `