mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 12:30:04 +08:00
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:
co-authored by
Claude Opus 5.5
parent
d0bb9be51e
commit
8206c8c271
@@ -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
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user