Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
206 changes: 206 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
20 changes: 19 additions & 1 deletion __tests__/react-router-lazy-barrel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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']);
});
Expand Down
Loading