fix(react): a JSX tag imported from the project renders what its module exports (#2563)

The JSX render pass matched a tag by name and used the file's imports only
to break ties, so an index that forwards another module's component
declared no candidate and the tag took a namesake elsewhere: outline's
Sidebar/index.ts is `import Sidebar from "./App"; export default Sidebar`,
and <Sidebar> rendered Aside.tsx's styled Sidebar, not the AppSidebar that
App.tsx exports as observer(AppSidebar).

A tag imported from a module of the project now renders what that module
exports under the imported name, read by exportedComponent, the reader
lazy routes use: through an index or barrel, past a wrapper call. Two rules
join that reader, for routes too: a const holding another binding is that
binding (`const Avatar = AvatarWithHoverCard; export { Avatar }`; a let may
be reassigned, so it stays), and a memo wrapper's bodyless component node is
the component it wraps (`const MemoizedBlurhash = memo(Blurhash)`). A module
whose export is no component node (`export default () => ...`) links nothing
rather than a namesake. Sync records every file a tag's import is read
through as a synthesis input, so rewiring only the index redraws the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-14 23:22:28 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d0bb9be51e
commit 8206c8c271
6 changed files with 552 additions and 19 deletions
+1
View File
@@ -151,6 +151,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
## [1.6.2] - 2026-10-03
+206
View File
@@ -796,3 +796,209 @@ export function ProfileForm({ user }: { user: User }) {
expect(referenced).toEqual(['web/src/forms/profile-form.tsx']);
});
});
/**
* A tag imported from a module of the project renders what that module exports
* under the imported name, read the way a lazy route's module is: through an
* `index` or a barrel that hands it on, and past a wrapper call around it. The
* name only broke ties among the candidates in the module the import resolves
* to, so an `index` that forwards its default export declared none of them,
* and the name picked among the rest: outline's `<Sidebar>` rendered
* `Aside.tsx`'s styled `Sidebar`, not the `AppSidebar` its index forwards.
*/
describe('JSX child: a tag imported from a module of the project', () => {
let dir: string;
let cg: any;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-project-import-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0","mobx-react":"^9.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();
}
/** `name file:line` of each node a jsx-render edge out of `parent` points at. */
const renders = (parent: string): string[] =>
cg.db.db
.prepare(
`SELECT t.name || ' ' || t.file_path || ':' || t.start_line 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);
it('follows an index that forwards another module’s default export', async () => {
// outline: `Sidebar/index.ts` is `import Sidebar from "./App"; export
// default Sidebar;`, and `App.tsx` exports `observer(AppSidebar)`.
write('app/components/Sidebar/Aside.tsx', 'export function Sidebar() {\n return <aside />;\n}\n');
write(
'app/components/Sidebar/App.tsx',
"import { observer } from 'mobx-react';\n\nfunction AppSidebar() {\n return <nav />;\n}\n\nexport default observer(AppSidebar);\n"
);
write('app/components/Sidebar/index.ts', "import Sidebar from './App';\n\nexport default Sidebar;\n");
write(
'app/components/AuthenticatedLayout.tsx',
"import Sidebar from './Sidebar';\n\nexport function AuthenticatedLayout() {\n return <main><Sidebar /></main>;\n}\n"
);
await index();
expect(renders('AuthenticatedLayout')).toEqual(['AppSidebar app/components/Sidebar/App.tsx:3']);
});
it('follows an index that exports an import again, through `React.memo(X, areEqual)`', async () => {
// excalidraw: `canvases/index.tsx` is `import StaticCanvas from
// "./StaticCanvas"; export { StaticCanvas };`.
write('a-decoy/StaticCanvas.tsx', 'export function StaticCanvas() {\n return <canvas id="decoy" />;\n}\n');
write(
'components/canvases/StaticCanvas.tsx',
"import React from 'react';\n\nconst StaticCanvas = () => {\n return <canvas />;\n};\n\nconst areEqual = () => true;\n\nexport default React.memo(StaticCanvas, areEqual);\n"
);
write('components/canvases/index.tsx', "import StaticCanvas from './StaticCanvas';\n\nexport { StaticCanvas };\n");
write(
'components/App.tsx',
"import { StaticCanvas } from './canvases';\n\nexport function App() {\n return <div><StaticCanvas /></div>;\n}\n"
);
await index();
expect(renders('App')).toEqual(['StaticCanvas components/canvases/StaticCanvas.tsx:3']);
});
it('renders a default export imported under another name, not the module’s other component', async () => {
// outline's `Button.tsx` exports a styled `Inner` beside `export default
// React.forwardRef<…>(Button)`.
write(
'components/Button.tsx',
`import React from 'react';
export const Inner = React.forwardRef<HTMLSpanElement, object>((props, ref) => <span ref={ref} {...props} />);
function Button(props: object, ref: React.Ref<HTMLButtonElement>) {
return <button ref={ref}><Inner /></button>;
}
export default React.forwardRef<HTMLButtonElement, object>(Button);
`
);
write(
'components/Toolbar.tsx',
"import ToolbarButton from './Button';\n\nexport function Toolbar() {\n return <div><ToolbarButton /></div>;\n}\n"
);
await index();
expect(renders('Toolbar')).toEqual(['Button components/Button.tsx:5']);
});
it('renders its own app’s component through a barrel when every app has one of that name', async () => {
// bulletproof-react: each app imports `Button` from its own
// `@/components/ui/button`, a barrel of `export * from './button'`.
for (const app of ['nextjs-app', 'react-vite']) {
write(`apps/${app}/tsconfig.json`, JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }));
write(`apps/${app}/src/components/ui/button/button.tsx`, 'export function Button() {\n return <button />;\n}\n');
write(`apps/${app}/src/components/ui/button/index.ts`, "export * from './button';\n");
}
write(
'apps/react-vite/src/features/auth/login-form.tsx',
"import { Button } from '@/components/ui/button';\n\nexport function LoginForm() {\n return <form><Button /></form>;\n}\n"
);
await index();
expect(renders('LoginForm')).toEqual(['Button apps/react-vite/src/components/ui/button/button.tsx:1']);
});
it('renders the component a memoized export wraps, not the wrapper', async () => {
// mastodon's `blurhash.tsx`: `const MemoizedBlurhash = memo(Blurhash);
// export { MemoizedBlurhash as Blurhash };`. The wrapper's node has no body
// to render from; `Blurhash`'s has the markup.
write(
'app/javascript/mastodon/components/blurhash.tsx',
"import { memo } from 'react';\n\nfunction Blurhash({ hash }: { hash: string }) {\n return <canvas data-hash={hash} />;\n}\n\nconst MemoizedBlurhash = memo(Blurhash);\n\nexport { MemoizedBlurhash as Blurhash };\n"
);
write(
'app/javascript/mastodon/components/media_item.tsx',
"import { Blurhash } from './blurhash';\n\nexport function MediaItem() {\n return <div><Blurhash hash=\"x\" /></div>;\n}\n"
);
await index();
expect(renders('MediaItem')).toEqual(['Blurhash app/javascript/mastodon/components/blurhash.tsx:3']);
});
it('follows a value an index holds another module’s component in', async () => {
// outline's `Avatar/index.ts`: `const Avatar = AvatarWithHoverCard; export
// { Avatar }`. The value has no body; the hover card renders the avatar.
write(
'app/components/Avatar/Avatar.tsx',
"import { observer } from 'mobx-react';\n\nfunction Avatar() {\n return <img />;\n}\n\nexport default observer(Avatar);\n"
);
write(
'app/components/Avatar/AvatarWithHoverCard.tsx',
"import { observer } from 'mobx-react';\nimport Avatar from './Avatar';\n\nfunction AvatarWithHoverCard() {\n return <div><Avatar /></div>;\n}\n\nexport default observer(AvatarWithHoverCard);\n"
);
write(
'app/components/Avatar/index.ts',
"import AvatarWithHoverCard from './AvatarWithHoverCard';\n\nconst Avatar = AvatarWithHoverCard;\n\nexport { Avatar };\n"
);
write(
'app/components/CommentForm.tsx',
"import { Avatar } from '~/components/Avatar';\n\nexport function CommentForm() {\n return <form><Avatar /></form>;\n}\n"
);
write('tsconfig.json', JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '~/*': ['./app/*'] } } }));
await index();
expect(renders('CommentForm')).toEqual(['AvatarWithHoverCard app/components/Avatar/AvatarWithHoverCard.tsx:4']);
expect(renders('AvatarWithHoverCard')).toEqual(['Avatar app/components/Avatar/Avatar.tsx:3']);
});
it('keeps a `let` the module exports, which may hold another component by then', async () => {
write('components/Primary.tsx', 'export function Primary() {\n return <b />;\n}\n');
write('components/Secondary.tsx', 'export function Secondary() {\n return <i />;\n}\n');
write(
'components/Button.ts',
"import { Primary } from './Primary';\nimport { Secondary } from './Secondary';\n\nlet Button = Primary;\nif (globalThis.compact) Button = Secondary;\n\nexport { Button };\n"
);
write(
'components/Toolbar.tsx',
"import { Button } from './Button';\n\nexport function Toolbar() {\n return <div><Button /></div>;\n}\n"
);
await index();
expect(renders('Toolbar')).toEqual(['Button components/Button.ts:4']);
});
it('renders no namesake elsewhere for a module whose default export has no name', async () => {
// outline's email `Footer.tsx`: `export const Link = …` above `export
// default ({ … }) => …`. The default export is no node, and neither the
// module's `Link` nor the app's own `Footer` is it.
write('app/components/Footer.tsx', 'export function Footer() {\n return <div className="app-footer" />;\n}\n');
write(
'emails/components/Footer.tsx',
"export const Link = ({ href }: { href: string }) => <a href={href} />;\n\nexport default ({ url }: { url: string }) => (\n <footer><Link href={url} /></footer>\n);\n"
);
write(
'emails/Welcome.tsx',
"import Footer from './components/Footer';\n\nexport function Welcome() {\n return <div><Footer url=\"/\" /></div>;\n}\n"
);
await index();
expect(renders('Welcome')).toEqual([]);
});
it('still renders the module’s own namesake when its export can’t be read (CommonJS)', async () => {
write('a-decoy/Card.jsx', 'export function Card() {\n return <div id="decoy" />;\n}\n');
write('components/Card.js', "const React = require('react');\n\nfunction Card() {\n return React.createElement('div');\n}\n\nmodule.exports = Card;\n");
write(
'components/Deck.jsx',
"import Card from './Card';\n\nexport function Deck() {\n return <section><Card /></section>;\n}\n"
);
await index();
expect(renders('Deck')).toEqual(['Card components/Card.js:3']);
});
});
+19 -1
View File
@@ -36,6 +36,7 @@ export const router = createBrowserRouter([
{ path: '/fleet', lazy: () => import('@/fleet/home_page') },
{ path: '/login', lazy: () => import('@/auth/login_page') },
{ path: '/graph', lazy: () => import('@/chronicle/graph_view') },
{ path: '/media', lazy: () => import('@/media/media_page') },
{ path: '/settings', element: <Settings /> },
{ path: '/redirect', lazy: () => import('@/core/redirection_loader') },
{ path: '/clash', lazy: () => import('@/clash') },
@@ -89,6 +90,17 @@ export const Component = BuilderListPage;
}
export { GraphView as Component };
`,
// A memoized export, whose wrapper has no body of its own to render from.
'src/media/media_page.tsx': `import { memo } from 'react';
function MediaPage() {
return null;
}
const MemoizedMediaPage = memo(MediaPage);
export { MemoizedMediaPage as Component };
`,
// `React.lazy` reads the default export, through a barrel too.
'src/settings/index.ts': `export { default } from './settings_page';
@@ -159,7 +171,8 @@ describe('a lazy route module that is a barrel', () => {
});
it('follows a barrel that forwards another barrel', () => {
expect(renders('/builders')).toEqual(['Component@src/builds/pages/builder_list_page/builder_list_page.tsx']);
// `export const Component = BuilderListPage` holds the page: the route renders it.
expect(renders('/builders')).toEqual(['BuilderListPage@src/builds/pages/builder_list_page/builder_list_page.tsx']);
});
it('renders inside the layout a barrel forwards', () => {
@@ -175,6 +188,11 @@ describe('a lazy route module that is a barrel', () => {
expect(renders('/graph')).toEqual(['GraphView@src/chronicle/graph_view.tsx']);
});
it('renders the component a memoized export wraps, not the wrapper', () => {
// mastodon's `blurhash.tsx` exports `memo(Blurhash)` this way.
expect(renders('/media')).toEqual(['MediaPage@src/media/media_page.tsx']);
});
it('follows the barrel a React.lazy value loads', () => {
expect(renders('/settings')).toEqual(['SettingsPage@src/settings/settings_page.tsx']);
});
@@ -0,0 +1,176 @@
/**
* A sync redraws a JSX tag when a module its import is read through changes.
*
* A tag imported from a module of the project renders what that module
* exports under the name, through an `index` that forwards it — outline's
* `Sidebar/index.ts` is `import Sidebar from './App'; export default
* Sidebar` — and past a wrapper around it (`export default
* observer(AppSidebar)`). So the tag's edge depends on files that hold no
* markup and no synthesized edge of their own: rewiring the index, or the
* default export behind it, must redraw the tag, as a fresh index would draw
* it, though the file that renders the tag is untouched.
*
* Every case compares the synced graph with a fresh index of the same files in
* a folder of its own: indexing again over an existing index skips its
* unchanged files, so it is no stand-in for one.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
type Files = Record<string, string | null>;
const pkg = JSON.stringify({ name: 'app', dependencies: { react: '^18', 'mobx-react': '^9' } });
/** Two pages that render `<Sidebar>` through the same forwarding index. */
const BASE: Files = {
'package.json': pkg,
'src/components/Sidebar/Aside.tsx': 'export function Sidebar() {\n return <aside />;\n}\n',
'src/components/Sidebar/App.tsx':
"import { observer } from 'mobx-react';\n\nfunction AppSidebar() {\n return <nav />;\n}\n\nexport default observer(AppSidebar);\n",
'src/components/Sidebar/Shared.tsx':
"import { observer } from 'mobx-react';\n\nfunction SharedSidebar() {\n return <nav />;\n}\n\nexport default observer(SharedSidebar);\n",
'src/components/Sidebar/index.ts': "import Sidebar from './App';\n\nexport default Sidebar;\n",
'src/scenes/Home.tsx': "import Sidebar from '../components/Sidebar';\n\nexport function Home() {\n return <main><Sidebar /></main>;\n}\n",
'src/scenes/Settings.tsx':
"import Sidebar from '../components/Sidebar';\n\nexport function Settings() {\n return <main><Sidebar /></main>;\n}\n",
};
let root: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = undefined;
});
function write(dir: string, files: Files): void {
for (const [file, text] of Object.entries(files)) {
const full = path.join(dir, file);
if (text === null) {
fs.rmSync(full, { force: true });
continue;
}
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, text);
}
}
/** Every node and edge by natural key, edge metadata included, so two indexes of the same files compare directly. */
function graphOf(graph: CodeGraph): string[] {
const keys = new Map(
graph.getFiles()
.flatMap((file) => graph.getNodesInFile(file.path))
.map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
);
const edges = graph.getOutgoingEdgesFrom([...keys.keys()]).map((e) =>
`${keys.get(e.source)} -${e.kind}-> ${keys.get(e.target) ?? e.target} ${JSON.stringify(e.metadata ?? null)} @${e.line ?? ''}:${e.column ?? ''}`);
return [...[...keys.values()].map((k) => `node ${k}`), ...edges].sort();
}
/** `<parent> -> <child file>::<child>` for every JSX render edge. */
function renders(graph: CodeGraph): string[] {
const out: string[] = [];
for (const file of graph.getFiles()) {
for (const node of graph.getNodesInFile(file.path)) {
for (const e of graph.getOutgoingEdges(node.id)) {
if (e.metadata?.synthesizedBy !== 'jsx-render') continue;
const child = graph.getNode(e.target);
out.push(`${node.name} -> ${child?.filePath}::${child?.name}`);
}
}
}
return out.sort();
}
/** The graph of a fresh index of `files`, in a folder of its own. */
async function freshGraph(files: Files): Promise<string[]> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-forward-fresh-'));
try {
write(dir, Object.fromEntries(Object.entries(files).filter(([, text]) => text !== null)));
const fresh = await CodeGraph.init(dir, { index: true });
try {
return graphOf(fresh);
} finally {
fresh.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/**
* Index `initial`, then write each step and sync after it — with `scoped`, a
* sync of only the step's files, as the file watcher runs it. Returns the
* final files.
*/
async function indexThenSync(opts: { scoped: boolean }, initial: Files, ...steps: Files[]): Promise<Files> {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-forward-'));
write(root, initial);
cg = await CodeGraph.init(root, { index: true });
const files = { ...initial };
for (const step of steps) {
write(root, step);
Object.assign(files, step);
await cg.sync(opts.scoped ? { paths: Object.keys(step) } : {});
}
return files;
}
describe('sync redraws a tag imported through a module that forwards it', () => {
it('renders the forwarded component on a fresh index', async () => {
await indexThenSync({ scoped: false }, BASE);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/App.tsx::AppSidebar',
'Settings -> src/components/Sidebar/App.tsx::AppSidebar',
]);
}, 60_000);
it.each([false, true])('when only the index changes which module it forwards (scoped=%s)', async (scoped) => {
const files = await indexThenSync(
{ scoped },
BASE,
// Neither page changes, and the index has no markup of its own.
{ 'src/components/Sidebar/index.ts': "import Sidebar from './Shared';\n\nexport default Sidebar;\n" }
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/Shared.tsx::SharedSidebar',
'Settings -> src/components/Sidebar/Shared.tsx::SharedSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
it('when the index starts holding the other module’s component in a value', async () => {
const files = await indexThenSync(
{ scoped: true },
BASE,
// outline's `Avatar/index.ts` shape: `const Avatar = AvatarWithHoverCard; export { Avatar }`.
{ 'src/components/Sidebar/index.ts': "import Shared from './Shared';\n\nconst Sidebar = Shared;\n\nexport default Sidebar;\n" }
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/Shared.tsx::SharedSidebar',
'Settings -> src/components/Sidebar/Shared.tsx::SharedSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
it('when the module behind the index wraps another component', async () => {
const files = await indexThenSync(
{ scoped: false },
BASE,
{
'src/components/Sidebar/App.tsx':
"import { observer } from 'mobx-react';\n\nfunction AppSidebar() {\n return <nav />;\n}\n\nfunction CompactSidebar() {\n return <nav />;\n}\n\nexport default observer(CompactSidebar);\n",
}
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/App.tsx::CompactSidebar',
'Settings -> src/components/Sidebar/App.tsx::CompactSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
});
+90 -7
View File
@@ -40,7 +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';
import { declaredComponent, exportedComponent, 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;
@@ -1707,6 +1707,9 @@ const JSX_CHILD_KINDS = new Set<NodeKind>(['component', 'function', 'class']);
*/
const JSX_CHILD_LANGUAGES = [...JS_FAMILY, 'vue', 'svelte'];
/** A file whose tags the JSX pass reads: a script, or a single-file component's. */
const JSX_INPUT_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/;
/**
* Whether a JSX tag can render `node` (`jsxChild`): a script's component,
* function or class named with a tag's capital, or any component, which a
@@ -1753,6 +1756,49 @@ function importedFromPackage(ctx: ResolutionContext, name: string, file: string)
return !!binding && ctx.isOutOfRepoImport?.(binding.source, file, language) === true;
}
/**
* The module of the project `file` imports `name` from, and the export the
* import names: `default` for a default import, else the imported name.
* Undefined when the file imports no such name from a module the index holds:
* a package's (`importedFromPackage`), or a path only the app's build resolves.
*/
function projectImport(ctx: ResolutionContext, name: string, file: string): { module: string; exported: string } | undefined {
const language = languageForJsxFile(file);
const binding = ctx.getImportMappings(file, language).find((m) => m.localName === name);
if (!binding || binding.isNamespace) return undefined;
const module = binding.resolvedPath ?? resolveImportPath(binding.source, file, language, ctx);
if (!module || !ctx.getNodesInFile(module).some((n) => n.kind === 'file')) return undefined;
return { module, exported: binding.isDefault || binding.exportedName === 'default' ? 'default' : binding.exportedName };
}
/**
* The files a JSX file's project-imported tags are read through: each tag's
* module, the index or barrel that hands its component on, and the module
* that declares it (`exportedComponent`'s reads). A sync that edits one of
* them — `index.ts` forwarding another module, a default export rewired —
* redraws the tag. `memo` holds each module and export's reads for one run.
*/
function importedTagModules(ctx: ResolutionContext, file: string, content: string, memo: Map<string, Set<string>>): Set<string> {
const out = new Set<string>();
JSX_TAG_RE.lastIndex = 0;
const names = new Set<string>();
for (let m = JSX_TAG_RE.exec(content); m; m = JSX_TAG_RE.exec(content)) names.add(m[1]!);
for (const name of names) {
const imported = projectImport(ctx, name, file);
if (!imported) continue;
const key = `${imported.module}\0${imported.exported}`;
let reads = memo.get(key);
if (!reads) {
reads = new Set<string>();
exportedComponent(imported.module, imported.exported, ctx, new Set(), reads);
memo.set(key, reads);
}
for (const read of reads) out.add(read);
}
out.delete(file);
return out;
}
/**
* The component a JSX tag names, among every node that shares the name.
*
@@ -1770,9 +1816,19 @@ function importedFromPackage(ctx: ResolutionContext, name: string, file: string)
* shape, and the one an import can never disambiguate. Then a name the file
* imports from a package: that package's component, which is no node here, so
* nothing. On SigNoz, antd's `<Button>` rendered the one project `Button` there
* 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.
* was, a styled link in a 404 page's styles. Then a name it imports from a
* module of the project: the component that module exports under it, read the
* way a lazy route's module is (`exportedComponent`), through an `index` or a
* barrel that hands it on and past a wrapper call. outline's
* `Sidebar/index.ts` is `import Sidebar from "./App"; export default
* Sidebar`, and `./App` exports `observer(AppSidebar)`; the index declares
* nothing, so `<Sidebar>` took the styled `Sidebar` of `Aside.tsx` by name.
* When the module exports nothing the graph holds as a component under the
* name — `export default () => …`, a type — no namesake elsewhere is it either,
* though one the module itself declares still is (a CommonJS `module.exports =
* Card`). 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,
@@ -1787,7 +1843,8 @@ function jsxChild(
name: string,
file: string,
importsOf: () => Map<string, string>,
ownValueOf: (name: string) => ReturnType<typeof declaredComponent>
ownValueOf: (name: string) => ReturnType<typeof declaredComponent>,
exportOf: (module: string, exported: string) => Node | null
): Node | undefined {
const own = ownValueOf(name);
if (own) return own.component ?? own.declaration;
@@ -1795,6 +1852,14 @@ function jsxChild(
const local = candidates.find((n) => n.filePath === file);
if (local) return local;
if (importedFromPackage(ctx, name, file)) return undefined;
const imported = projectImport(ctx, name, file);
if (imported) {
const exported = exportOf(imported.module, imported.exported);
if (exported) return exported;
const inModule = candidates.find((n) => n.filePath === imported.module);
if (inModule) return inModule;
if (candidates.length > 0) return undefined;
}
if (candidates.length === 0) {
// A name nothing declares is the file's DEFAULT import of a module's one
// component under another name: segmented-control renders
@@ -1881,6 +1946,19 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
const edges: Edge[] = [];
const seen = new Set<string>();
const PARENT_KINDS = new Set(['method', 'function', 'component']);
// Once per module and export for the whole pass: what a tag imported from
// the project renders. The lookup reads and comment-strips the module.
const exports = new Map<string, Node | null>();
const exportOf = (module: string, exported: string): Node | null => {
const key = `${module}\0${exported}`;
let node = exports.get(key);
if (node === undefined) {
const id = exportedComponent(module, exported, ctx);
node = id ? ctx.getNodeById?.(id) ?? null : null;
exports.set(key, node);
}
return node;
};
let scanned = 0;
for (const file of ctx.getAllFiles()) {
if ((++scannedFiles & 15) === 0) await onYield();
@@ -1939,7 +2017,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, ownValueOf);
let child = jsxChild(ctx, name, file, importsOf, ownValueOf, exportOf);
// 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);
@@ -4681,14 +4759,19 @@ export async function synthesizeCallbackEdges(
// depends only on the files, so with a pool the main thread reads them while
// 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.
// own: a page given its default export, or a barrel rewired, redraws it. So
// does every file a tag imported from the project is read through.
const collectInputs = async (): Promise<string[]> => {
const found = new Set<string>();
const tagModules = new Map<string, Set<string>>();
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(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);
if (JSX_INPUT_FILE.test(file) && (content.includes('</') || content.includes('/>'))) {
for (const read of importedTagModules(ctx, file, content, tagModules)) found.add(read);
}
}
await yieldToLoop();
}
+60 -11
View File
@@ -501,11 +501,14 @@ const MAX_REEXPORT_HOPS = 4;
* `export { default } from './page'`, `export { Page as Component } from
* './page'`, `export * from './page'`, which forwards every name but the
* default, or an import it exports again, `import { Page } from './page';
* export { Page }`. As in JavaScript, a name the file exports itself hides one an
* `export *` would forward, and a name two `export *` modules both forward is
* exported by neither. `reads` collects every file the lookup reads.
* export { Page }`, or in a value, outline's `const Avatar =
* AvatarWithHoverCard; export { Avatar }`. As in JavaScript, a name the file
* exports itself hides one an `export *` would forward, and a name two
* `export *` modules both forward is exported by neither. `reads` collects
* every file the lookup reads. A JSX tag imported from the project renders it
* too (`jsxChild`).
*/
function exportedComponent(
export function exportedComponent(
file: string,
name: string,
context: ResolutionContext,
@@ -523,17 +526,40 @@ function exportedComponent(
const code = stripCommentsForRegex(context.readFile(file) ?? '', 'typescript');
const local = ownExport(code, name);
if (local) {
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];
const nodes = context.getNodesInFile(file);
const declaredAs = (binding: string): Node | undefined => {
const named = nodes.filter((n) => n.name === binding && EXPORTED_COMPONENT_KINDS.has(n.kind));
return named.find((n) => n.qualifiedName === binding) ?? named[0];
};
const exported = declaredAs(local);
// A constant that holds another binding is that binding: outline's
// `Avatar/index.ts` is `const Avatar = AvatarWithHoverCard; export { Avatar }`.
// (A `let` may be assigned another component before it is used.)
let binding = local;
let declared = exported;
for (let hop = 0; declared && hop < MAX_REEXPORT_HOPS; hop++) {
const held = declared.kind === 'constant' ? heldBinding(declared, context) : null;
if (!held) break;
binding = held;
declared = declaredAs(held);
}
// 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;
// So is a component node minted for a wrapper around another component
// (`const MemoizedBlurhash = memo(Blurhash)`), which has no body to render from.
if (declared) {
const wrapped = declared.kind === 'constant' || declared.kind === 'variable' ? wrappedComponent(declared, context)
: declared.kind === 'component' ? handedComponent(declared, context) : null;
return (wrapped ?? declared).id;
}
// A name the module imports and exports again — `import { Page } from
// './page'; export { Page }`, `import Page from './Page'; export default
// Page` — is the page that import names. `export default withAuth(Page)`
// hands over no binding of its own.
if (name === 'default' && !exportsBinding(code, local)) return null;
const imported = context.getImportMappings(file, language).find((m) => m.localName === local);
return imported && !imported.isNamespace ? forwarded(imported.source, imported.isDefault ? 'default' : imported.exportedName) : null;
if (binding === local && name === 'default' && !exportsBinding(code, local)) return null;
const imported = context.getImportMappings(file, language).find((m) => m.localName === binding);
const id = imported && !imported.isNamespace ? forwarded(imported.source, imported.isDefault ? 'default' : imported.exportedName) : null;
// A held binding that leads nowhere leaves the value itself the export.
return id ?? exported?.id ?? null;
}
const reExports = context.getReExports?.(file, language) ?? [];
for (const re of reExports) {
@@ -801,10 +827,33 @@ function wrappedComponent(value: Node, context: ResolutionContext): Node | null
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;
return handedComponent(value, context, nodes);
}
/**
* The binding a value holds and nothing more — `AvatarWithHoverCard` of
* outline's `const Avatar = AvatarWithHoverCard`, past a type assertion (`=
* Page as FC`) — or null for any other initializer.
*/
function heldBinding(value: Node, context: ResolutionContext): string | null {
const code = stripCommentsForRegex(declarationText(value, context), 'typescript');
const init = initializerAt(code, value.name.length);
if (init < 0) return null;
const held = /^\s*([A-Za-z_$][\w$]*)\s*(?:$|(?:as|satisfies)\s)/.exec(code.slice(init, statementEnd(code, init)));
return held && held[1] !== value.name ? held[1]! : null;
}
/**
* The component of its own file a wrapper call around a declaration hands
* over: `const TableView = observer(TableViewInner)`, or mastodon's `const
* MemoizedBlurhash = memo(Blurhash)`, whose component node has no body of its
* own to render from.
*/
function handedComponent(value: Node, context: ResolutionContext, nodes = context.getNodesInFile(value.filePath)): Node | null {
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)
return handed && isPascalCase(handed) && handed !== value.name
? nodes.find((n) => n.name === handed && n.qualifiedName === handed && COMPONENT_KINDS.has(n.kind)) ?? null
: null;
}