fix(react): a component a file declares itself as a value is what its JSX renders (#2420)

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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-14 20:39:08 +00:00
committed by GitHub
parent 9b237093a3
commit d0bb9be51e
7 changed files with 681 additions and 53 deletions
+1
View File
@@ -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 `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. 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')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` 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 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
+295
View File
@@ -501,3 +501,298 @@ export function PostList({ posts }: { posts: IPost[] }) {
expect(renders('PostList')).toEqual([]);
});
});
/**
* 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`. 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;
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<void> {
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) => <Component {...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 <div />;\n}\n');
write('vite/src/pages/auth/Register.jsx', 'export default function Register() {\n return <div />;\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 <main><RegisterPage /></main>;
}
`
);
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 <div />;\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 <main>Sign in <LoginPage /></main>;
}
`
);
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) => <div ref={ref} />);\nexport default { Excalidraw };\n');
write('examples/with-nextjs/src/excalidrawWrapper.tsx', 'const ExcalidrawWrapper = () => {\n return <div />;\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 <Excalidraw />;
}
`
);
write('a-decoy/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
write('charts/line-chart.tsx', 'export function LineChart() {\n return <svg />;\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 <LineChart />;
}
`
);
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 <form />;\n}\n');
write(
'app/scenes/Login/Login.tsx',
"import { observer } from 'mobx-react';\nfunction Login() {\n return <form />;\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 <Login />;
}
`
);
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 <div />;\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 <div />;
});
function TableViewInner() {
return <table />;
}
const TableView = observer(TableViewInner) as typeof TableViewInner;
export function Settings() {
return <DialogPortal><Application /><TableView /></DialogPortal>;
}
`
);
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 the branch of a conditional that loads lazily, and a wrapper typed past its call or given options', async () => {
// SigNoz loads its devtools in development only and memoizes a table body
// with a comparator; mastodon types its `forwardRef` with an object type
// that keeps the generics.
write('a-decoy/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <div />;\n}\n');
write('a-decoy/Combobox.tsx', 'export function Combobox() {\n return <div />;\n}\n');
write('a-decoy/MemoizedRows.tsx', 'export function MemoizedRows() {\n return <div />;\n}\n');
write('src/lib/devtools/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <dialog />;\n}\n');
write(
'src/palette.tsx',
`import React, { forwardRef } from 'react';
const IS_DEV = process.env.NODE_ENV === 'development';
const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('./lib/devtools/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;
const ComboboxWithRef = <Item extends { id: string }>(
props: { items: Item[] },
ref: React.ForwardedRef<HTMLInputElement>,
) => {
return <input ref={ref} />;
};
export const Combobox = forwardRef(ComboboxWithRef) as {
<Item extends { id: string }>(props: { items: Item[] }): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};
function Rows({ rows }: { rows: string[] }) {
return <ul>{rows.map((row) => <li key={row}>{row}</li>)}</ul>;
}
const MemoizedRows = React.memo(
Rows,
(prev, next) => prev.rows === next.rows,
) as typeof Rows;
export function Palette() {
return <div>{AuthZDevModal && <AuthZDevModal />}<Combobox items={[]} /><MemoizedRows rows={[]} /></div>;
}
`
);
await index();
expect(renders('Palette')).toEqual([
'function AuthZDevModal src/lib/devtools/AuthZDevModal.tsx',
'function ComboboxWithRef src/palette.tsx',
'function Rows src/palette.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 <div />;\n}\n');
write('heavy.tsx', 'export default function Heavy() {\n return <div />;\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<typeof Schema>;
const Dialog = withFallback('Dialog', (props) => {
const load = () => import('./heavy');
return <div onClick={load} />;
});
export function Form() {
const form = useForm<Schema>();
return <form><Dialog /></form>;
}
`
);
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 <div />;\n}\n');
write(
'web/src/forms/profile-form.tsx',
`import { z } from 'zod';
const User = z.object({ name: z.string() });
type User = z.infer<typeof User>;
export function ProfileForm({ user }: { user: User }) {
return <form>{user.name}</form>;
}
`
);
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']);
});
});
+51
View File
@@ -1755,3 +1755,54 @@ export function About() {
expect({ bindings: routeBindings(cg), navigations: navigations(cg) }).toEqual(await fresh(false));
}, 60_000);
});
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: <MainLayout />,
children: [
{
path: 'dashboard',
element: <Dashboard />
}
]
};
export default MainRoutes;
`,
'vite/src/layout/MainLayout/index.jsx': 'export default function Layout() {\n return <main />;\n}\n',
'vite/src/views/Dashboard.jsx': 'export default function Dashboard() {\n return <div />;\n}\n',
'vite/src/components/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => <Component {...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 <main>{children}</main>;\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']);
});
});
+2 -1
View File
@@ -168,7 +168,8 @@ describe('sync redraws a route that renders a same-file lazy value when its modu
// module it loads leads to it.
'/docs': ['src/pages/Docs.tsx::DocsPage'],
});
expect(renders(cg!)).toEqual(['App -> src/pages/Team.tsx::Team']);
// A tag naming a lazy value renders the page that value loads, named like it or not.
expect(renders(cg!)).toEqual(['App -> src/pages/Docs.tsx::DocsPage', 'App -> src/pages/Team.tsx::Team']);
expect(cg!.getPendingReferenceCount()).toBe(0);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
File diff suppressed because one or more lines are too long
+31 -7
View File
@@ -40,6 +40,7 @@ import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequest
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
import { declaredComponent, lazyValueModules } 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;
@@ -1772,13 +1773,24 @@ function importedFromPackage(ctx: ResolutionContext, name: string, file: string)
* was, a styled link in a 404 page's styles. 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 comes first of all: `const Chart =
* lazy(() => import('./Chart'))` renders the component that module exports,
* `observer(function Settings() {…})` the function it wraps, and anything
* else the declaration itself (`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.
*/
function jsxChild(
ctx: ResolutionContext,
name: string,
file: string,
importsOf: () => Map<string, string>
importsOf: () => Map<string, string>,
ownValueOf: (name: string) => ReturnType<typeof declaredComponent>
): Node | undefined {
const own = ownValueOf(name);
if (own) return own.component ?? own.declaration;
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
const local = candidates.find((n) => n.filePath === file);
if (local) return local;
@@ -1856,7 +1868,8 @@ function declaredInside(ctx: ResolutionContext, name: string, parent: Node, chil
* a tag, not only in a type argument or parameter list (`useState<User>()`,
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
* read its `Document` model class as a child), and the name must resolve to a
* component/function/class node. A name the parent binds itself by its first
* component/function/class node, or to a value the file declares itself (see
* `jsxChild`). A name the parent binds itself by its first
* tag — a parameter, or a `const` like outline's `const Content = variant ===
* "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that
* local, and renders only a component declared inside the parent
@@ -1884,6 +1897,12 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let imports: Map<string, string> | 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<string, ReturnType<typeof declaredComponent>>();
const ownValueOf = (name: string) => {
if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx));
return ownValues.get(name);
};
// A function renders the tags its own lines hold, so functions that span
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
@@ -1920,7 +1939,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
for (const [name, line] of names.tags) {
if (added >= MAX_JSX_CHILDREN) break;
if (!line) continue;
let child = jsxChild(ctx, name, file, importsOf);
let child = jsxChild(ctx, name, file, importsOf, ownValueOf);
// A name the parent binds itself by its first tag is that local.
if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) {
child = declaredInside(ctx, name, parent, child);
@@ -4660,15 +4679,20 @@ export async function synthesizeCallbackEdges(
// Files whose text could feed a pass (inputs that produce NO edges included,
// e.g. an over-cap channel: deleting one may make the full pass viable). It
// depends only on the files, so with a pool the main thread reads them while
// the workers run the passes instead of after.
// the workers run the passes instead of after. A module a file's own lazy
// value loads feeds the JSX that names the value, even with no markup of its
// own: a page given its default export, or a barrel rewired, redraws it.
const collectInputs = async (): Promise<string[]> => {
const found: string[] = [];
const found = new Set<string>();
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (content !== null && hasSynthesisPattern(file, content)) found.push(file);
if (content !== null && hasSynthesisPattern(file, content)) {
found.add(file);
if (/\bimport\s*\(/.test(content)) for (const read of lazyValueModules(file, ctx)) found.add(read);
}
await yieldToLoop();
}
return found;
return [...found];
};
let inputs: string[] | null = null;
+278 -43
View File
@@ -84,15 +84,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 type: the
// module a `const RegisterPage = Loadable(lazy(() =>
// import('pages/auth/Register')))` loads, the function a value wraps, 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) {
@@ -212,9 +212,9 @@ export const reactResolver: FrameworkResolver = {
/**
* The modules a route's answer is read from: a lazily loaded page or layout
* (`lazy-import:` references, and `import:…#…` for the export a loader
* picks), or the one a same-file lazy value the route renders loads — the
* references Pattern 1 asks `declaredComponent` about — with the modules a
* barrel there forwards the lookup to.
* picks), or the one a same-file lazy value the route renders, or renders
* inside, loads (`declaredComponent`) — with the modules a barrel there
* forwards the lookup to.
*/
lazyModules(ref: UnresolvedRef, context: ResolutionContext): readonly string[] {
const name = ref.referenceName;
@@ -227,8 +227,17 @@ export const reactResolver: FrameworkResolver = {
let spec: string | null | undefined;
if (name.startsWith(LAZY_ROUTE_PREFIX)) spec = name.slice(LAZY_ROUTE_PREFIX.length);
else if (name.startsWith(LAYOUT_PREFIX + LAZY_ROUTE_PREFIX)) spec = name.slice(LAYOUT_PREFIX.length + LAZY_ROUTE_PREFIX.length);
else if ((ref.language === 'tsx' || ref.language === 'jsx') && isPascalCase(name) && !isBuiltInType(name) &&
ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) spec = declaredLoader(name, ref.filePath, context);
else if (isReactRouteRef(ref)) {
// A page or layout the route file declares as a value it loads lazily.
const own = name.startsWith(LAYOUT_PREFIX) ? name.slice(LAYOUT_PREFIX.length) : name;
const loaded = isPascalCase(own) && !isBuiltInType(own) ? declaredLoader(own, ref.filePath, context) : undefined;
if (loaded && loaded.member !== 'default') {
const reads = new Set<string>();
pickedExport(loaded.spec, loaded.member, ref.filePath, context, reads);
return [...reads];
}
spec = loaded?.spec;
}
return spec ? lazyRouteFiles(spec, ref.filePath, context) : [];
},
};
@@ -435,9 +444,15 @@ function readObjectPath(text: string, at: number, keys: string[]): string | null
return null;
}
/** The file a route's lazily loaded module is, from the file that loads it. */
/**
* The file a lazily loaded module is, from the file that loads it: by the
* extensions that file's own imports resolve with (`./MainLayout` is
* `MainLayout/index.jsx` from a `.jsx` file), else by TypeScript's.
*/
function lazyModuleFile(spec: string, fromFile: string, context: ResolutionContext): string | null {
return resolveImportPath(spec, fromFile, 'typescript', context);
const language = scriptLanguage(fromFile);
return resolveImportPath(spec, fromFile, language, context) ??
(language === 'typescript' ? null : resolveImportPath(spec, fromFile, 'typescript', context));
}
/** The component a lazy route module renders: its default export, else its `Component` export. */
@@ -508,8 +523,10 @@ function exportedComponent(
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;
const named = context.getNodesInFile(file).filter((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind));
const declared = named.find((n) => n.qualifiedName === local) ?? named[0];
// A value is what it wraps: `export default Page` of `const Page = observer(function Page_() {…})`.
if (declared) return (declared.kind === 'constant' || declared.kind === 'variable' ? wrappedComponent(declared, context)?.id : undefined) ?? 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)`
@@ -534,13 +551,14 @@ function exportedComponent(
}
/**
* The local name a module's own `export` gives `name`: `export default Page`
* (or `function Page`, `class Page`), `export function Component`, `export
* const Component`, `export { Page as Component }`. Null when it has none.
* The local name a module's own `export` gives `name`: its default export
* (`defaultExportedName`: `export default Page`, `function Page`, `class
* Page`, `observer(Page)`), `export function Component`, `export const
* Component`, `export { Page as Component }`. Null when it has none.
*/
function ownExport(source: string, name: string): string | null {
if (name === 'default') {
const declared = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1];
const declared = defaultExportedName(source);
if (declared) return declared;
} else if (new RegExp(String.raw`\bexport\s+(?:async\s+)?(?:const|let|var|function|class)\s+${name.replace(/\$/g, '\\$')}(?![\w$])`).test(source)) {
return name;
@@ -548,37 +566,247 @@ function ownExport(source: string, name: string): string | null {
return extractLocalExportAliases(source).find((a) => a.exportedName === name)?.localName ?? null;
}
/**
* The name a module's default export carries: `export default Login`,
* `export default function Login`, `export default class Login`, `export
* default Login as FC`, 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 named = /^(?:async\s+)?(?:function\s*\*?\s*|class\s+)([A-Za-z_$][\w$]*)/.exec(rest) ??
/^([A-Za-z_$][\w$]*)(?:[ \t]*(?:;|\r?\n|$)|\s+(?:as|satisfies)\s)/.exec(rest);
return named ? named[1]! : wrappedName(rest.slice(0, statementEnd(rest, 0)));
}
/**
* The component a wrapper call hands over — `observer(Login)`, `React.memo(Login)`,
* `connect(mapState)(Login)`, `withTranslation()(withRouter(Login))`: its last
* argument list's lone argument, or a component ahead of the options after it
* (`React.memo(TableBody, areEqual)`, `withAuthZ(Page, { … })`), past a type
* assertion on the call (`as typeof Login`, or mastodon's
* `forwardRef(ComboboxWithRef) as { … }`, which keeps the generics
* `forwardRef` drops).
*/
function wrappedName(expression: string): string | null {
const s = expression.trim();
const callee = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:<[^()]*>)?\s*(?=\()/.exec(s);
if (!callee) return null;
let end = callee[0].length;
let args: string[] = [];
for (let open = end; s[open] === '('; ) {
const close = matchBracket(s, open);
if (close < 0) return null;
args = topLevelItems(s.slice(open + 1, close));
end = close + 1;
for (open = end; /\s/.test(s[open] ?? ''); ) open++;
}
if (!/^(?:\s+(?:as|satisfies)\s[\s\S]*)?$/.test(s.slice(end))) return null;
const first = args[0] ?? '';
// `connect(mapState, mapDispatch)` hands over no component yet.
if (args.length > 1 && !/^[A-Z]/.test(first)) return null;
return /^[A-Za-z_$][\w$]*$/.test(first) ? first : first ? wrappedName(first) : 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 defaultExportBinding(source) === 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(()
* => 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).
* A value `filePath` declares itself under a component's name, and what it
* renders. JavaScript scoping makes the name, used anywhere in that file —
* `<RegisterPage />`, a route's `element: <RegisterPage />` — 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 (`declaredLoader`), 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).
*/
function declaredComponent(name: string, filePath: string, context: ResolutionContext): string | null | undefined {
const spec = declaredLoader(name, filePath, context);
if (spec === undefined) return undefined;
return spec ? lazyRouteComponent(spec, filePath, context) : null;
export function declaredComponent(
name: string,
filePath: string,
context: ResolutionContext,
): { declaration: Node; component: Node | null } | undefined {
const declaration = declaredValue(name, filePath, context);
if (!declaration) return undefined;
const loaded = valueLoader(declaration, context);
if (!loaded) return { declaration, component: wrappedComponent(declaration, context) };
const id = loaded.member === 'default'
? lazyRouteComponent(loaded.spec, filePath, context)
: pickedExport(loaded.spec, loaded.member, filePath, context);
return { declaration, component: id ? context.getNodeById?.(id) ?? null : null };
}
/**
* The module a value `filePath` declares as `name` loads lazily — the
* `'./x'` of `const X = lazy(() => import('./x'))`, wrapped or not — null
* for a value that loads none, undefined when the file declares no such value.
* The module a value `filePath` declares as `name` loads lazily, and the
* export its loader picks (`valueLoader`) — null for a value that loads none,
* undefined when the file declares no such value.
*/
function declaredLoader(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');
return /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1] ?? null;
function declaredLoader(name: string, filePath: string, context: ResolutionContext): { spec: string; member: string } | null | undefined {
const declaration = declaredValue(name, filePath, context);
return declaration ? valueLoader(declaration, context) : undefined;
}
/**
* The modules the values `filePath` declares load lazily, with the barrels the
* lookups for their components pass through: what a JSX tag naming one of
* them is drawn from (`declaredComponent`), so a sync that changes one —
* gives a page its default export, rewires a barrel — redraws the tag.
*/
export function lazyValueModules(filePath: string, context: ResolutionContext): string[] {
const reads = new Set<string>();
for (const node of context.getNodesInFile(filePath)) {
if ((node.kind !== 'constant' && node.kind !== 'variable') || node.qualifiedName !== node.name || !isPascalCase(node.name)) continue;
const loaded = valueLoader(node, context);
if (!loaded) continue;
if (loaded.member === 'default') lazyRouteComponent(loaded.spec, filePath, context, reads);
else pickedExport(loaded.spec, loaded.member, filePath, context, reads);
}
return [...reads];
}
/** The module-level `const`, `let` or `var` `filePath` declares as `name`. */
function declaredValue(name: string, filePath: string, context: ResolutionContext): Node | undefined {
return (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name))
.find((n) => (n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === name);
}
/**
* The module a value's initializer hands a lazy loader, and the export the
* loader resolves to — `default` unless it picks one: `lazy(() =>
* import('./x'))`, wrapped or not (`Loadable(lazy(…))`, `loadable(…)`,
* `dynamic(…, { ssr: false })`, `Loadable({ loader: () => import(…) })`),
* `dynamic(async () => (await import('./x')).default)`, or a named export
* (`.then((m) => ({ default: m.Chart }))`, `.then((m) => m.Chart)`), in either
* branch of a conditional (`IS_DEV ? React.lazy(…) : null`). The loader is
* read by `loaderValue`, as a data router's is; one it can't read loads the
* module its expression's first import names. A function the value wraps is
* no loader (`withFallback('Dialog', (props) => { … import('mermaid') … })`):
* what it returns renders, and what it imports is its own business.
*/
function valueLoader(declaration: Node, context: ResolutionContext): { spec: string; member: string } | null {
const code = stripCommentsForRegex(declarationText(declaration, context), 'typescript');
const init = initializerAt(code, declaration.name.length);
const loader = init < 0 ? null : loaderOf(code.slice(init));
if (!loader) return null;
const page = loadedPage(loaderValue(loader, new Map()));
if (page) return page;
const fn = functionParts(loader);
const spec = fn && !fn.block ? FIRST_IMPORT.exec(fn.body)?.[1] : undefined;
return spec ? { spec, member: 'default' } : null;
}
/**
* The loader function an initializer hands a lazy wrapper: the first function
* among its call's arguments, through the wrappers around it (`Loadable(lazy(()
* => import('./x')))`) and before the options after it (`dynamic(…, { ssr:
* false })`), or an options object's `loader` (`Loadable({ loader: () =>
* import(…) })`). Null for an initializer that is no such call.
*/
function loaderOf(text: string): string | null {
const s = text.trim();
const call = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:<[^()]*>)?\s*\(/.exec(s);
if (!call) return conditionalLoader(s);
const open = call[0].length - 1;
const close = matchBracket(s, open);
if (close < 0) return null;
for (const arg of topLevelItems(s.slice(open + 1, close))) {
const item = arg.trim();
if (!item) continue;
if (functionParts(item)) return item;
if (item.startsWith('{')) {
const loader = matchBracket(item, 0) === item.length - 1 ? objectEntries(item.slice(1, -1)).props.get('loader') : undefined;
if (loader !== undefined && functionParts(loader)) return loader;
continue;
}
const inner = loaderOf(item);
if (inner) return inner;
}
return conditionalLoader(s);
}
/**
* The loader of a conditional's one branch that has one: SigNoz's `IS_DEV ?
* React.lazy(() => import(…)) : null`. Null when neither branch loads, or both
* do — the value is then one or the other.
*/
function conditionalLoader(text: string): string | null {
const branches = conditionalBranches(text);
const loaders = branches ? branches.map(loaderOf).filter((l): l is string => l !== null) : [];
return loaders.length === 1 ? loaders[0]! : null;
}
/** The two branches of a conditional expression, `test ? yes : no`; null for any other expression. */
function conditionalBranches(text: string): [string, string] | null {
let question = -1;
let open = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"' || ch === "'" || ch === '`' || ch === '{' || ch === '(' || ch === '[') {
i = ch === '{' || ch === '(' || ch === '[' ? matchBracket(text, i) : skipString(text, i);
if (i < 0) return null;
} else if (ch === '?') {
// `??` and `?.` are no conditional.
if (text[i + 1] === '?' || (text[i + 1] === '.' && !/\d/.test(text[i + 2] ?? ''))) i++;
else if (open++ === 0) question = i;
} else if (ch === ':' && open > 0 && --open === 0) {
return [text.slice(question + 1, i), text.slice(i + 1)];
}
}
return null;
}
/**
* The page a lazy wrapper's loader resolves to: a module's default export, an
* export it picks, or the `default` of the object `React.lazy` wants (`{
* default: m.Chart }`).
*/
function loadedPage(value: Loaded | null): { spec: string; member: string } | null {
if (value?.kind === 'module') return { spec: value.spec, member: 'default' };
if (value?.kind === 'export') return { spec: value.spec, member: value.name };
if (value?.kind !== 'object') return null;
const inner = objectEntries(value.text).props.get('default');
return inner === undefined ? null : loadedPage(valueOf(inner, value.scope));
}
/** 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');
}
/**
* 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 code = stripCommentsForRegex(declarationText(value, context), 'typescript');
const init = initializerAt(code, value.name.length);
const handed = init < 0 ? null : wrappedName(code.slice(init, statementEnd(code, init)));
return handed && isPascalCase(handed)
? nodes.find((n) => n.name === handed && n.qualifiedName === handed && COMPONENT_KINDS.has(n.kind)) ?? null
: null;
}
/**
@@ -592,7 +820,14 @@ function layoutComponent(spec: string, ref: UnresolvedRef, context: ResolutionCo
const picked = isReactRouteRef(ref) ? PICKED_EXPORT.exec(spec) : null;
if (picked) return pickedExport(picked[1]!, picked[2]!, ref.filePath, context);
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 there is no component to find.
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