From 894ce2ebff58a4d14c01cea585dc34155bf9ff8c Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 00:39:55 -0500 Subject: [PATCH 1/3] fix(react): a component a file declares itself as a value is what its JSX renders A JSX file that declares a component as a value - a lazily loaded page (`const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))`, `dynamic(async () => (await import('../wrapper')).default)`), a wrapped one (`observer(function Settings() {...})`, `observer(TableViewInner)`) or an alias or styled component (`const DialogPortal = DialogPrimitive.Portal`) - lost the name to a same-named component anywhere in the repository: the lookups behind component references (`resolveComponent`) and JSX children (`jsxChild`) keep only component, function and class nodes, and a value is a `constant`. codedthemes' mantis bound its vite app's routes to the Next.js app's `RegisterPage`; #2400 fixed that for route references only. `declaredComponent` is now the one rule for every React component name: a name the file declares as a module-level value is that declaration - the component of the module its own loader imports, else the function it wraps, else the declaration itself. It applies to every Pattern 1 reference, a route layout the file declares, and every `jsx-render` child, where a `()`), not a tag. The lazy-module lookup #2400 added now reads the loader's chosen export (`.then((m) => ({ default: m.Chart }))`, `(await import(...)).default`), follows a module that only forwards its component (`import Login from './Login'; export default Login`, `export { default } from`, `export *`) and a wrapped default export (`export default observer(Login)`), resolves with the importing file's own extensions before TypeScript's (a `.jsx` file's `./MainLayout` is `MainLayout/index.jsx`), and ignores an import inside a function body the value wraps. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/jsx-child-disambiguation.test.ts | 238 +++++++++++++++++++++ __tests__/react-router.test.ts | 51 +++++ docs/design/framework-coverage.md | 20 +- src/resolution/callback-synthesizer.ts | 41 +++- src/resolution/frameworks/react.ts | 224 +++++++++++++++---- 6 files changed, 529 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4a..de5dba9f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. +- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))` or a `dynamic(() => import(…))` component, links to the component its module exports, even through an `index` file that only passes it on or an `export default observer(Login)`; a wrapped component like `const Settings = observer(function Settings() {…})` links to the function it wraps; and an alias or styled component like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router routes and layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 5c8465a900..8928808fa1 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -129,3 +129,241 @@ export function Header() { return

; } expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']); }); }); + +/** + * A tag the file declares itself as a VALUE — a lazily loaded page, a + * wrapped component, an alias — is that declaration, whatever else in the + * repository shares its name. A value is no component node, so the lookup by + * kind used to skip it and take a lone same-named function anywhere: on + * codedthemes' mantis (a vite app and a Next.js app in one repository) the + * vite app's `RegisterPage` went to the Next.js app's page, and excalidraw's + * Next.js example rendered the docs site's `Excalidraw`. + */ +describe('JSX child: a value the file declares itself', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-own-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}'); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + async function index(): Promise { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + } + + /** `kind name file` of each node a jsx-render edge out of `parent` points at. */ + const renders = (parent: string): string[] => + cg.db.db + .prepare( + `SELECT t.kind || ' ' || t.name || ' ' || t.file_path AS r FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + ORDER BY r` + ) + .all(parent) + .map((row: any) => row.r); + + const LOADABLE = 'export default function Loadable(Component) {\n return (props) => ;\n}\n'; + + it('renders the module a lazy value loads, never another app’s same-named page', async () => { + write('next/src/app/(auth)/register/page.jsx', 'export default function RegisterPage() {\n return
;\n}\n'); + write('vite/src/pages/auth/Register.jsx', 'export default function Register() {\n return
;\n}\n'); + write('vite/src/components/Loadable.jsx', LOADABLE); + write( + 'vite/src/layout/AuthShell.jsx', + `import { lazy } from 'react'; +import Loadable from '../components/Loadable'; + +const RegisterPage = Loadable(lazy(() => import('../pages/auth/Register'))); + +export function AuthShell() { + return
; +} +` + ); + await index(); + expect(renders('AuthShell')).toEqual(['function Register vite/src/pages/auth/Register.jsx']); + }); + + it('renders the declaration itself when the module it loads is out of reach', async () => { + write('next/src/app/(auth)/login/page.jsx', 'export default function LoginPage() {\n return
;\n}\n'); + write('vite/src/components/Loadable.jsx', LOADABLE); + write( + 'vite/src/layout/AuthShell.jsx', + `import { lazy } from 'react'; +import Loadable from '../components/Loadable'; + +// An import only the app's own build resolves (\`baseUrl\`). +const LoginPage = Loadable(lazy(() => import('pages/auth/Login'))); + +export function AuthShell() { + return
Sign in
; +} +` + ); + await index(); + expect(renders('AuthShell')).toEqual(['constant LoginPage vite/src/layout/AuthShell.jsx']); + }); + + it('reads next/dynamic’s `(await import(…)).default` and a `.then` that picks a named export', async () => { + // excalidraw's docs site has a component of the same name. + write('dev-docs/src/theme/ReactLiveScope/index.js', 'const Excalidraw = React.forwardRef((props, ref) =>
);\nexport default { Excalidraw };\n'); + write('examples/with-nextjs/src/excalidrawWrapper.tsx', 'const ExcalidrawWrapper = () => {\n return
;\n};\nexport default ExcalidrawWrapper;\n'); + write( + 'examples/with-nextjs/src/pages/excalidraw-in-pages.tsx', + `import dynamic from 'next/dynamic'; + +const Excalidraw = dynamic( + async () => (await import('../excalidrawWrapper')).default, + { + ssr: false, + }, +); + +export default function Page() { + return ; +} +` + ); + write('a-decoy/line-chart.tsx', 'export function LineChart() {\n return ;\n}\n'); + write('charts/line-chart.tsx', 'export function LineChart() {\n return ;\n}\n'); + write('charts/index.ts', "export default function Fallback() {\n return null;\n}\nexport * from './line-chart';\n"); + write( + 'dashboard.tsx', + `import { lazy } from 'react'; +const LineChart = lazy(() => import('./charts').then((m) => ({ default: m.LineChart }))); +export function Dashboard() { + return ; +} +` + ); + await index(); + expect(renders('Page')).toEqual(['function ExcalidrawWrapper examples/with-nextjs/src/excalidrawWrapper.tsx']); + expect(renders('Dashboard')).toEqual(['function LineChart charts/line-chart.tsx']); + }); + + it('follows a module that forwards its default export to the component it wraps', async () => { + // outline: `scenes/Login/index.ts` forwards `Login.tsx`'s `observer(Login)`. + write('a-decoy/Login.tsx', 'export function Login() {\n return
;\n}\n'); + write( + 'app/scenes/Login/Login.tsx', + "import { observer } from 'mobx-react';\nfunction Login() {\n return ;\n}\n\nexport default observer(Login);\n" + ); + write('app/scenes/Login/index.ts', 'import Login from "./Login";\n\nexport default Login;\n'); + write( + 'app/scenes/Shared/index.tsx', + `import { lazy } from 'react'; +const Login = lazy(() => import('../Login')); +export function SharedScene() { + return ; +} +` + ); + await index(); + expect(renders('SharedScene')).toEqual(['function Login app/scenes/Login/Login.tsx']); + }); + + it('renders the function a value wraps, and an alias value itself', async () => { + write('a-decoy/portal.tsx', 'export function DialogPortal() {\n return
;\n}\n'); + write( + 'app/settings.tsx', + `import { observer } from 'mobx-react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; + +const DialogPortal = DialogPrimitive.Portal; + +const Application = observer(function Application() { + return
; +}); + +function TableViewInner() { + return ; +} + +const TableView = observer(TableViewInner) as typeof TableViewInner; + +export function Settings() { + return ; +} +` + ); + write('a-decoy/table-view.tsx', 'export class TableView {}\n'); + await index(); + expect(renders('Settings')).toEqual([ + 'constant DialogPortal app/settings.tsx', + 'function Application app/settings.tsx', + 'function TableViewInner app/settings.tsx', + ]); + }); + + it('renders nothing for a type argument naming the value, nor for an import inside a function it wraps', async () => { + write('a-decoy/schema.tsx', 'export function Schema() {\n return
;\n}\n'); + write('heavy.tsx', 'export default function Heavy() {\n return
;\n}\n'); + write( + 'form.tsx', + `import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { withFallback } from './fallback'; + +const Schema = z.object({ name: z.string() }); +type Schema = z.infer; + +const Dialog = withFallback('Dialog', (props) => { + const load = () => import('./heavy'); + return
; +}); + +export function Form() { + const form = useForm(); + return ; +} +` + ); + write('fallback.tsx', 'export function withFallback(name, render) {\n return render;\n}\n'); + await index(); + expect(renders('Form')).toEqual(['constant Dialog form.tsx']); + }); + + it('binds a TSX type named like a value the file declares to that file, never another app’s component', async () => { + // Component resolution saw the name as a component's and took the only + // one in the repository, in another app. + write('admin/src/components/User.tsx', 'export function User() {\n return
;\n}\n'); + write( + 'web/src/forms/profile-form.tsx', + `import { z } from 'zod'; + +const User = z.object({ name: z.string() }); +type User = z.infer; + +export function ProfileForm({ user }: { user: User }) { + return
{user.name}; +} +` + ); + await index(); + const referenced = cg.db.db + .prepare( + `SELECT DISTINCT t.file_path AS f FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'ProfileForm' AND e.kind = 'references'` + ) + .all() + .map((row: any) => row.f); + expect(referenced).toEqual(['web/src/forms/profile-form.tsx']); + }); +}); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index 1ae76ff784..e595475c58 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -1124,6 +1124,57 @@ export default AuthenticationRoutes; }); }); +describe('react-router: a layout the route file loads lazily', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'mantis', private: true }), + 'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^7' } }), + 'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom'; +import MainRoutes from './MainRoutes'; + +const router = createBrowserRouter([MainRoutes]); + +export default router; +`, + 'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react'; +import Loadable from '../components/Loadable'; + +const MainLayout = Loadable(lazy(() => import('../layout/MainLayout'))); +const Dashboard = Loadable(lazy(() => import('../views/Dashboard'))); + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: 'dashboard', + element: + } + ] +}; + +export default MainRoutes; +`, + 'vite/src/layout/MainLayout/index.jsx': 'export default function Layout() {\n return
;\n}\n', + 'vite/src/views/Dashboard.jsx': 'export default function Dashboard() {\n return
;\n}\n', + 'vite/src/components/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => ;\n}\n', + // The repository's other app, with a layout of the same name. + 'next/package.json': JSON.stringify({ name: 'next-app', dependencies: { next: '^15', react: '^18' } }), + 'next/src/layout/MainLayout.jsx': 'export default function MainLayout({ children }) {\n return
{children}
;\n}\n', + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('binds the layout to the module the route file loads, never to a same-named layout of another app', () => { + expect(routeBindings(cg)).toEqual(['/ -> Layout', '/dashboard -> Dashboard', '/dashboard ~> Layout']); + }); +}); + describe('react-router: a table mapped inside in the same file', () => { let root: string; let cg: CodeGraph; diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index c4a3765d2d..d23f9f94d1 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -1,6 +1,6 @@ # Framework & language coverage — what is done, what is left -**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-06) against the build at that date. Re-verify with the +**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-07) against the build at that date. Re-verify with the queries in [Checking this file is still true](#checking-this-file-is-still-true) before trusting a row; this is a snapshot, not a live view. @@ -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) | +| 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). A page or layout the route file declares as a value binds to what that value renders (trap 14): outline/outline's lazily loaded screens reach the component behind an `index` that forwards it and an `export default observer(X)` (4 routes move from the declaration to `SharedScene` and `Login`) | | 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) | @@ -283,6 +283,22 @@ Each of these cost real debugging time; they are not hypothetical. itself mounted under another file's `` reads as if at the root, and a navigation that failed while its route was gone is not retried until a full index. +14. **A name the file declares itself is that declaration.** A screen's + component is often a value, not a function: `const RegisterPage = + Loadable(lazy(() => import('pages/auth/Register')))`, `dynamic(async () => + (await import('../wrapper')).default)`, `observer(function Settings() {…})`, + `const DialogPortal = DialogPrimitive.Portal`, or a `styled.div` + the extractor leaves a `constant`. A lookup that keeps only component, + function and class nodes skips the value and takes a lone same-named + component anywhere: codedthemes' mantis bound its vite app's routes to the + Next.js app's `RegisterPage`, and excalidraw's Next.js example rendered + the docs site's `Excalidraw`. `declaredComponent` in `frameworks/react.ts` + is the one rule for every React component name — a route's `element` and + layout, a component reference, a `jsx-render` child: the component of the + module the value's own loader imports (the export it picks, through an + `index` that forwards it and an `export default observer(X)`), else the + function the value wraps, else the declaration. A `()`), not a tag. --- diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ebecb41a82..6158435509 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -40,6 +40,7 @@ import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequest import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; import { crossesCodeBoundary } from './name-matcher'; +import { declaredComponent } from './frameworks/react'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -1369,13 +1370,27 @@ function importedFrom(ctx: ResolutionContext, file: string, language: Language): * shape, and the one an import can never disambiguate. Then the file the name * is imported from. Then the language, which only decides a tie: a `.tsx` tag * naming both a TS component and a same-named Swift class means the TS one. + * + * A value the file declares under the name is the tag's, whatever else shares + * it: `const Chart = lazy(() => import('./Chart'))` renders the component that + * module exports, `observer(function Settings() {…})` the function it wraps, + * and anything else the declaration itself (see `declaredComponent`). + * Kind alone left it out — a value is no component node — so a lone function + * of that name anywhere won: mantis's vite app rendered the `RegisterPage` of + * the repository's Next.js app. `asTag` says some `()` under `const Schema = z.object(…)` renders + * nothing). */ function jsxChild( ctx: ResolutionContext, name: string, file: string, - importsOf: () => Map + importsOf: () => Map, + ownValueOf: (name: string) => ReturnType, + asTag: boolean ): Node | undefined { + const own = ownValueOf(name); + if (own) return asTag ? own.component ?? own.declaration : undefined; const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind)); if (candidates.length === 0) { // A name nothing declares is the file's DEFAULT import of a module's one @@ -1408,8 +1423,9 @@ function jsxChild( * so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the * JSX hop. Link parent → each capitalized JSX child it renders. File-oriented * (read each JSX file once). Precision gate: the child name must resolve to a - * component/function/class node — TS generics like `Array` resolve to a type - * (or nothing) and are dropped. + * component/function/class node, or to a value the file declares itself and + * writes as a tag — TS generics like `Array` resolve to a type (or + * nothing) and are dropped. */ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { let scannedFiles = 0; @@ -1432,17 +1448,28 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): let imports: Map | null = null; const importsOf = () => (imports ??= importedFrom(ctx, file, parents[0]!.language)); + // Once per file and name: the value the file declares under it, if any. + const ownValues = new Map>(); + const ownValueOf = (name: string) => { + if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx)); + return ownValues.get(name); + }; for (const parent of parents) { const src = sliceLines(content, parent.startLine, parent.endLine); if (!src || (!src.includes(''))) continue; - const names = new Set(); + // Each name, and whether a ``, `Array`). + const names = new Map(); JSX_TAG_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + while ((m = JSX_TAG_RE.exec(src))) { + const asTag = !/[\w$]/.test(src[m.index - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, m.index - 7), m.index)); + names.set(m[1]!, (names.get(m[1]!) ?? false) || asTag); + } let added = 0; - for (const name of names) { + for (const [name, asTag] of names) { if (added >= MAX_JSX_CHILDREN) break; - const child = jsxChild(ctx, name, file, importsOf); + const child = jsxChild(ctx, name, file, importsOf, ownValueOf, asTag); if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue; const key = `${parent.id}>${child.id}`; if (seen.has(key)) continue; diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 72d05c91d8..a1734719af 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -47,7 +47,7 @@ export const reactResolver: FrameworkResolver = { if (!REACT_SCRIPT_LANGUAGES.has(ref.language)) return null; if (ref.referenceName.startsWith(LAZY_ROUTE_PREFIX)) { const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); - return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; + return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'framework' } : null; } // The layout a route renders inside: what happens in it — its header's // links, its logout — happens on the route's screen too. @@ -77,15 +77,15 @@ export const reactResolver: FrameworkResolver = { isPascalCase(ref.referenceName) && !isBuiltInType(ref.referenceName) ) { - // What a route renders, when its own file declares it: the module a - // `const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))` - // loads, or else that declaration, which name matching binds — never a - // same-named component in another app of the repository. - if (ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) { - const own = declaredComponent(ref.referenceName, ref.filePath, context); - if (own !== undefined) { - return own ? { original: ref, targetNodeId: own, confidence: 0.9, resolvedBy: 'framework' } : null; - } + // A name the file declares itself is that declaration, whatever refers + // to it — a route's `element`, a page's default export, a `typeof X`: + // the module a `const RegisterPage = Loadable(lazy(() => + // import('pages/auth/Register')))` loads, or else the declaration, which + // name matching binds — never a same-named component in another app of + // the repository. + const own = declaredComponent(ref.referenceName, ref.filePath, context); + if (own) { + return own.component ? { original: ref, targetNodeId: own.component.id, confidence: 0.9, resolvedBy: 'framework' } : null; } const result = resolveComponent(ref.referenceName, ref.filePath, context); if (result) { @@ -398,35 +398,178 @@ function readObjectPath(text: string, at: number, keys: string[]): string | null return null; } -/** The component a lazy route module renders: its default export, else its `Component` export. */ -function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null { - const file = resolveImportPath(spec, fromFile, 'typescript', context); +/** What a module can export as a component: a value holds one as well as a declaration. */ +const EXPORTED_COMPONENT_KINDS = new Set(['function', 'component', 'class', 'constant', 'variable']); + +/** + * The component a lazily loaded module renders: the export its loader picks + * (`member`), and for the default one, its default export, else its + * `Component` export. A module that only forwards it — `import Login from + * './Login'; export default Login` in an `index`, `export { default } from + * './Login'`, `export * from './charts'` — is followed to the module that + * declares it. + */ +function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext, member = 'default', depth = 0): Node | null { + const file = depth <= 4 ? resolveModule(spec, fromFile, context) : null; if (!file) return null; - const source = context.readFile(file) ?? ''; - const named = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1] ?? - (/\bexport\s+(?:const|function|class)\s+Component\b/.test(source) ? 'Component' : null); - if (!named) return null; - const node = context.getNodesInFile(file).find((n) => n.name === named && - (n.kind === 'function' || n.kind === 'component' || n.kind === 'class' || n.kind === 'constant' || n.kind === 'variable')); - return node?.id ?? null; + let named: string | null = member; + if (member === 'default') { + const source = stripCommentsForRegex(context.readFile(file) ?? '', 'typescript'); + named = defaultExportedName(source) ?? (/\bexport\s+(?:const|function|class)\s+Component\b/.test(source) ? 'Component' : null); + } + const declared = named ? context.getNodesInFile(file).filter((n) => n.name === named && EXPORTED_COMPONENT_KINDS.has(n.kind)) : []; + const node = declared.find((n) => n.qualifiedName === named) ?? declared[0]; + if (node) return (node.kind === 'constant' || node.kind === 'variable' ? wrappedComponent(node, context) : null) ?? node; + const language = scriptLanguage(file); + const forwarded = named ? context.getImportMappings(file, language).find((m) => m.localName === named && !m.isNamespace) : undefined; + if (forwarded) { + return lazyRouteComponent(forwarded.source, file, context, forwarded.isDefault ? 'default' : forwarded.exportedName ?? named!, depth + 1); + } + const reExports = context.getReExports?.(file, language) ?? []; + for (const r of reExports) { + if (r.kind === 'named' && r.exportedName === member) return lazyRouteComponent(r.source, file, context, r.originalName, depth + 1); + } + if (member === 'default') return null; + for (const r of reExports) { + const found = r.kind === 'wildcard' ? lazyRouteComponent(r.source, file, context, member, depth + 1) : null; + if (found) return found; + } + return null; +} + +/** + * The file a module specifier names from `fromFile`: by the extensions that + * file's own imports resolve with (`./MainLayout` → `MainLayout/index.jsx` + * from a `.jsx` file), else by TypeScript's. + */ +function resolveModule(spec: string, fromFile: string, context: ResolutionContext): string | null { + const language = scriptLanguage(fromFile); + return resolveImportPath(spec, fromFile, language, context) ?? + (language === 'typescript' ? null : resolveImportPath(spec, fromFile, 'typescript', context)); +} + +/** + * The name a module's default export carries: `export default Login`, + * `export default function Login`, `export default class Login`, or the + * component a wrapper call hands over — `observer(Login)`, `React.memo(Login)`, + * `connect(mapState)(Login)`, `withTranslation()(withRouter(Login))`. + */ +function defaultExportedName(source: string): string | null { + const at = /\bexport\s+default\s+/.exec(source); + if (!at) return null; + const rest = source.slice(at.index + at[0].length); + const declared = /^(?:async\s+)?(?:function\s*\*?\s*|class\s+)([A-Za-z_$][\w$]*)/.exec(rest); + if (declared) return declared[1]!; + const bare = /^([A-Za-z_$][\w$]*)[ \t]*(?:;|\r?\n|$)/.exec(rest); + if (bare) return bare[1]!; + // A call expression, to its `;` or the line it ends on. + let depth = 0; + let end = rest.length; + for (let i = 0; i < rest.length && end === rest.length; i++) { + const ch = rest[i]!; + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + else if (depth <= 0 && (ch === ';' || ch === '\n')) end = i; + } + return wrappedName(rest.slice(0, end)); +} + +/** + * The component a wrapper call hands over — `observer(Login)`, `React.memo(Login)`, + * `connect(mapState)(Login)`, `withTranslation()(withRouter(Login))`: its last + * argument list's lone identifier, past a trailing `as typeof Login`. + */ +function wrappedName(expression: string): string | null { + const call = expression.trimEnd().replace(/\s+(?:as|satisfies)\s+[\w$.\s]+$/, ''); + return /\(\s*([A-Za-z_$][\w$]*)\s*\)(?:\s*\))*$/.exec(call)?.[1] ?? null; +} + +/** + * The component a value wraps, which is what it renders: the function it + * holds (`const Application = observer(function Application() {…})`), or the + * component of its own file a wrapper call is handed (`const TableView = + * observer(TableViewInner)`). An inner function counts when it has the value's + * name or ends where the value does — never a helper inside an arrow the graph + * has no node for (`withFallback('X', () => { const load = () => …; … })`). + */ +function wrappedComponent(value: Node, context: ResolutionContext): Node | null { + const nodes = context.getNodesInFile(value.filePath); + const inner = nodes.filter((n) => COMPONENT_KINDS.has(n.kind) && + n.qualifiedName === `${value.qualifiedName}::${n.name}` && n.startLine >= value.startLine && n.endLine <= value.endLine); + const held = inner.find((n) => n.name === value.name) ?? + (inner.length === 1 && inner[0]!.endLine === value.endLine ? inner[0]! : null); + if (held || inner.length > 0) return held; + const handed = wrappedName(declarationText(value, context).replace(/^[^=]*=\s*/, '')); + return handed && isPascalCase(handed) + ? nodes.find((n) => n.name === handed && n.qualifiedName === handed && COMPONENT_KINDS.has(n.kind)) ?? null + : null; +} + +/** + * A value `filePath` declares itself under a component's name, and what it + * renders. JavaScript scoping makes the name, used anywhere in that file — + * ``, a route's `element: ` — this + * declaration, never another file's same-named component: mantis's vite app + * bound its `RegisterPage`, a `Loadable(lazy(() => + * import('pages/auth/Register')))`, to the page of that name in the + * repository's Next.js app. `component` is the component of the module a lazy + * declaration loads, or the function the value wraps (`observer(function + * Login() {…})`); null when there is none to find, and the declaration is + * then the binding. Undefined when the file declares no such value (a + * function or class component is found the usual way). + */ +export function declaredComponent( + name: string, + filePath: string, + context: ResolutionContext, +): { declaration: Node; component: Node | null } | undefined { + const declaration = (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name)) + .find((n) => (n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === name); + if (!declaration) return undefined; + const lazy = lazyImportOf(declarationText(declaration, context)); + return { + declaration, + component: lazy ? lazyRouteComponent(lazy.spec, filePath, context, lazy.member) : wrappedComponent(declaration, context), + }; +} + +/** A declarator's own source text: `B = lazy(…)` in `const A = lazy(…), B = lazy(…)`. */ +function declarationText(node: Node, context: ResolutionContext): string { + const lines = (context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split(/\r?\n/) ?? []) + .slice(node.startLine - 1, node.endLine); + if (lines.length === 0) return ''; + if (node.endColumn > 0) lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, node.endColumn); + lines[0] = lines[0]!.slice(node.startColumn); + return lines.join('\n'); } /** - * 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(() - * => import('./x'))`, wrapped or not), null for a value that loads none it - * can find — the value is then the binding, by name — and undefined when the - * file declares no such value (a function or class component is found the - * usual way). + * The module a value's initializer hands a lazy loader, and the export the + * loader picks: `lazy(() => import('./x'))`, wrapped or not (`Loadable(lazy(…))`, + * `loadable(…)`, `dynamic(…, { ssr: false })`, `Loadable({ loader: () => + * import(…) })`), `dynamic(async () => (await import('./x')).default)`, and a + * named export picked by `.then((m) => ({ default: m.Chart }))`, `.then((m) => + * m.Chart)` or `.then(({ Chart }) => …)`. The loader must be the + * initializer's own: an import inside a function body the value wraps + * (`withFallback('Dialog', (props) => { … import('mermaid') … })`) is that + * function's, and loads nothing the value renders. */ -function declaredComponent(name: string, filePath: string, context: ResolutionContext): string | null | undefined { - const own = (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name)) - .find((n) => n.kind === 'constant' || n.kind === 'variable'); - if (!own) return undefined; - const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []; - const text = lines.slice(own.startLine - 1, own.endLine).join('\n'); - const spec = /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1]; - return spec ? lazyRouteComponent(spec, filePath, context) : null; +function lazyImportOf(text: string): { spec: string; member: string } | null { + const code = stripCommentsForRegex(text, 'typescript'); + const loader = /=>\s*(\(\s*await\s+)?import\s*\(\s*(["'])([^"'`]+)\2\s*\)/.exec(code); + if (!loader || /=>\s*\{|\bfunction\b/.test(code.slice(0, loader.index))) return null; + const rest = code.slice(loader.index + loader[0].length); + if (loader[1]) { + // `(await import('./x')).Chart`; the module itself is its default export. + return { spec: loader[3]!, member: /^\s*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(rest)?.[1] ?? 'default' }; + } + const then = /^\s*\.\s*then\s*\(\s*(?:async\s+)?(?:\(\s*([A-Za-z_$][\w$]*)\s*\)|([A-Za-z_$][\w$]*)|\(\s*\{\s*([A-Za-z_$][\w$]*))/.exec(rest); + if (!then) return { spec: loader[3]!, member: 'default' }; + if (then[3]) return { spec: loader[3]!, member: then[3] }; + const param = then[1] ?? then[2]!; + const picked = new RegExp(`^\\s*=>\\s*(?:\\(\\s*)?(?:\\{\\s*default\\s*:\\s*)?${param.replace(/\$/g, '\\$')}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`) + .exec(rest.slice(then[0].length)); + return picked ? { spec: loader[3]!, member: picked[1]! } : null; } /** @@ -435,14 +578,21 @@ function declaredComponent(name: string, filePath: string, context: ResolutionCo * declared in reach — or a lazily loaded layout module's component. */ function layoutComponent(spec: string, ref: UnresolvedRef, context: ResolutionContext): string | null { - if (spec.startsWith(LAZY_ROUTE_PREFIX)) return lazyRouteComponent(spec.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); + if (spec.startsWith(LAZY_ROUTE_PREFIX)) return lazyRouteComponent(spec.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context)?.id ?? null; const mapping = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === spec); - if (!mapping) return resolveComponent(spec, ref.filePath, context); + if (!mapping) { + // A layout the route file declares itself (`const MainLayout = + // Loadable(lazy(…))`): no name match follows a `layout:` reference, so + // the declaration binds here when its module is out of reach. + const own = declaredComponent(spec, ref.filePath, context); + if (own) return (own.component ?? own.declaration).id; + return resolveComponent(spec, ref.filePath, context); + } const viaImport = context.resolveImport?.({ ...ref, referenceName: spec })?.targetNodeId; if (viaImport) return viaImport; // `import { default as AppRoot } from './routes/app/root'`: read the module the // import names — never a same-named component somewhere else. - if (mapping.isDefault || mapping.exportedName === 'default') return lazyRouteComponent(mapping.source, ref.filePath, context); + if (mapping.isDefault || mapping.exportedName === 'default') return lazyRouteComponent(mapping.source, ref.filePath, context)?.id ?? null; const file = resolveImportPath(mapping.source, ref.filePath, ref.language, context); return file ? context.getNodesInFile(file).find((n) => n.name === mapping.exportedName && COMPONENT_KINDS.has(n.kind))?.id ?? null : null; } From 6bf08ae20399129c53c0ae8b3b6929c5fd34dada Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 00:58:18 -0500 Subject: [PATCH 2/3] test(react): say the value block also pins component resolution's rule Co-Authored-By: Claude Opus 5.5 --- __tests__/jsx-child-disambiguation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 8928808fa1..2dae276c42 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -137,7 +137,8 @@ export function Header() { return

; } * kind used to skip it and take a lone same-named function anywhere: on * codedthemes' mantis (a vite app and a Next.js app in one repository) the * vite app's `RegisterPage` went to the Next.js app's page, and excalidraw's - * Next.js example rendered the docs site's `Excalidraw`. + * Next.js example rendered the docs site's `Excalidraw`. Component resolution + * keeps the same rule for every other reference to such a name (the last case). */ describe('JSX child: a value the file declares itself', () => { let dir: string; From 3c4fb182e55019e78da44198d783930ecef06487 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 00:59:04 -0500 Subject: [PATCH 3/3] docs(changelog): show a lazy page whose import resolves Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5dba9f1e..0f1765c0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,7 +75,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. -- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))` or a `dynamic(() => import(…))` component, links to the component its module exports, even through an `index` file that only passes it on or an `export default observer(Login)`; a wrapped component like `const Settings = observer(function Settings() {…})` links to the function it wraps; and an alias or styled component like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router routes and layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. +- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))` or a `dynamic(() => import(…))` component, links to the component its module exports, even through an `index` file that only passes it on or an `export default observer(Login)`; a wrapped component like `const Settings = observer(function Settings() {…})` links to the function it wraps; and an alias or styled component like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router routes and layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03