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 @@ -150,6 +150,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
295 changes: 295 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,298 @@ export function PostList({ posts }: { posts: IPost[] }) {
expect(renders('PostList')).toEqual([]);
});
});

/**
* A tag the file declares itself as a VALUE — a lazily loaded page, a
* wrapped component, an alias — is that declaration, whatever else in the
* repository shares its name. A value is no component node, so the lookup by
* kind used to skip it and take a lone same-named function anywhere: on
* codedthemes' mantis (a vite app and a Next.js app in one repository) the
* vite app's `RegisterPage` went to the Next.js app's page, and excalidraw's
* Next.js example rendered the docs site's `Excalidraw`. Component resolution
* keeps the same rule for every other reference to such a name (the last case).
*/
describe('JSX child: a value the file declares itself', () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-own-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
});

afterEach(() => {
cg?.close?.();
fs.rmSync(dir, { recursive: true, force: true });
});

const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};

async function index(): Promise<void> {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
}

/** `kind name file` of each node a jsx-render edge out of `parent` points at. */
const renders = (parent: string): string[] =>
cg.db.db
.prepare(
`SELECT t.kind || ' ' || t.name || ' ' || t.file_path AS r FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
ORDER BY r`
)
.all(parent)
.map((row: any) => row.r);

const LOADABLE = 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n';

it('renders the module a lazy value loads, never another app’s same-named page', async () => {
write('next/src/app/(auth)/register/page.jsx', 'export default function RegisterPage() {\n return <div />;\n}\n');
write('vite/src/pages/auth/Register.jsx', 'export default function Register() {\n return <div />;\n}\n');
write('vite/src/components/Loadable.jsx', LOADABLE);
write(
'vite/src/layout/AuthShell.jsx',
`import { lazy } from 'react';
import Loadable from '../components/Loadable';

const RegisterPage = Loadable(lazy(() => import('../pages/auth/Register')));

export function AuthShell() {
return <main><RegisterPage /></main>;
}
`
);
await index();
expect(renders('AuthShell')).toEqual(['function Register vite/src/pages/auth/Register.jsx']);
});

it('renders the declaration itself when the module it loads is out of reach', async () => {
write('next/src/app/(auth)/login/page.jsx', 'export default function LoginPage() {\n return <div />;\n}\n');
write('vite/src/components/Loadable.jsx', LOADABLE);
write(
'vite/src/layout/AuthShell.jsx',
`import { lazy } from 'react';
import Loadable from '../components/Loadable';

// An import only the app's own build resolves (\`baseUrl\`).
const LoginPage = Loadable(lazy(() => import('pages/auth/Login')));

export function AuthShell() {
return <main>Sign in <LoginPage /></main>;
}
`
);
await index();
expect(renders('AuthShell')).toEqual(['constant LoginPage vite/src/layout/AuthShell.jsx']);
});

it('reads next/dynamic’s `(await import(…)).default` and a `.then` that picks a named export', async () => {
// excalidraw's docs site has a component of the same name.
write('dev-docs/src/theme/ReactLiveScope/index.js', 'const Excalidraw = React.forwardRef((props, ref) => <div ref={ref} />);\nexport default { Excalidraw };\n');
write('examples/with-nextjs/src/excalidrawWrapper.tsx', 'const ExcalidrawWrapper = () => {\n return <div />;\n};\nexport default ExcalidrawWrapper;\n');
write(
'examples/with-nextjs/src/pages/excalidraw-in-pages.tsx',
`import dynamic from 'next/dynamic';

const Excalidraw = dynamic(
async () => (await import('../excalidrawWrapper')).default,
{
ssr: false,
},
);

export default function Page() {
return <Excalidraw />;
}
`
);
write('a-decoy/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
write('charts/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
write('charts/index.ts', "export default function Fallback() {\n return null;\n}\nexport * from './line-chart';\n");
write(
'dashboard.tsx',
`import { lazy } from 'react';
const LineChart = lazy(() => import('./charts').then((m) => ({ default: m.LineChart })));
export function Dashboard() {
return <LineChart />;
}
`
);
await index();
expect(renders('Page')).toEqual(['function ExcalidrawWrapper examples/with-nextjs/src/excalidrawWrapper.tsx']);
expect(renders('Dashboard')).toEqual(['function LineChart charts/line-chart.tsx']);
});

it('follows a module that forwards its default export to the component it wraps', async () => {
// outline: `scenes/Login/index.ts` forwards `Login.tsx`'s `observer(Login)`.
write('a-decoy/Login.tsx', 'export function Login() {\n return <form />;\n}\n');
write(
'app/scenes/Login/Login.tsx',
"import { observer } from 'mobx-react';\nfunction Login() {\n return <form />;\n}\n\nexport default observer(Login);\n"
);
write('app/scenes/Login/index.ts', 'import Login from "./Login";\n\nexport default Login;\n');
write(
'app/scenes/Shared/index.tsx',
`import { lazy } from 'react';
const Login = lazy(() => import('../Login'));
export function SharedScene() {
return <Login />;
}
`
);
await index();
expect(renders('SharedScene')).toEqual(['function Login app/scenes/Login/Login.tsx']);
});

it('renders the function a value wraps, and an alias value itself', async () => {
write('a-decoy/portal.tsx', 'export function DialogPortal() {\n return <div />;\n}\n');
write(
'app/settings.tsx',
`import { observer } from 'mobx-react';
import * as DialogPrimitive from '@radix-ui/react-dialog';

const DialogPortal = DialogPrimitive.Portal;

const Application = observer(function Application() {
return <div />;
});

function TableViewInner() {
return <table />;
}

const TableView = observer(TableViewInner) as typeof TableViewInner;

export function Settings() {
return <DialogPortal><Application /><TableView /></DialogPortal>;
}
`
);
write('a-decoy/table-view.tsx', 'export class TableView {}\n');
await index();
expect(renders('Settings')).toEqual([
'constant DialogPortal app/settings.tsx',
'function Application app/settings.tsx',
'function TableViewInner app/settings.tsx',
]);
});

it('renders the branch of a conditional that loads lazily, and a wrapper typed past its call or given options', async () => {
// SigNoz loads its devtools in development only and memoizes a table body
// with a comparator; mastodon types its `forwardRef` with an object type
// that keeps the generics.
write('a-decoy/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <div />;\n}\n');
write('a-decoy/Combobox.tsx', 'export function Combobox() {\n return <div />;\n}\n');
write('a-decoy/MemoizedRows.tsx', 'export function MemoizedRows() {\n return <div />;\n}\n');
write('src/lib/devtools/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <dialog />;\n}\n');
write(
'src/palette.tsx',
`import React, { forwardRef } from 'react';

const IS_DEV = process.env.NODE_ENV === 'development';

const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('./lib/devtools/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;

const ComboboxWithRef = <Item extends { id: string }>(
props: { items: Item[] },
ref: React.ForwardedRef<HTMLInputElement>,
) => {
return <input ref={ref} />;
};

export const Combobox = forwardRef(ComboboxWithRef) as {
<Item extends { id: string }>(props: { items: Item[] }): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};

function Rows({ rows }: { rows: string[] }) {
return <ul>{rows.map((row) => <li key={row}>{row}</li>)}</ul>;
}

const MemoizedRows = React.memo(
Rows,
(prev, next) => prev.rows === next.rows,
) as typeof Rows;

export function Palette() {
return <div>{AuthZDevModal && <AuthZDevModal />}<Combobox items={[]} /><MemoizedRows rows={[]} /></div>;
}
`
);
await index();
expect(renders('Palette')).toEqual([
'function AuthZDevModal src/lib/devtools/AuthZDevModal.tsx',
'function ComboboxWithRef src/palette.tsx',
'function Rows src/palette.tsx',
]);
});

it('renders nothing for a type argument naming the value, nor for an import inside a function it wraps', async () => {
write('a-decoy/schema.tsx', 'export function Schema() {\n return <div />;\n}\n');
write('heavy.tsx', 'export default function Heavy() {\n return <div />;\n}\n');
write(
'form.tsx',
`import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { withFallback } from './fallback';

const Schema = z.object({ name: z.string() });
type Schema = z.infer<typeof Schema>;

const Dialog = withFallback('Dialog', (props) => {
const load = () => import('./heavy');
return <div onClick={load} />;
});

export function Form() {
const form = useForm<Schema>();
return <form><Dialog /></form>;
}
`
);
write('fallback.tsx', 'export function withFallback(name, render) {\n return render;\n}\n');
await index();
expect(renders('Form')).toEqual(['constant Dialog form.tsx']);
});

it('binds a TSX type named like a value the file declares to that file, never another app’s component', async () => {
// Component resolution saw the name as a component's and took the only
// one in the repository, in another app.
write('admin/src/components/User.tsx', 'export function User() {\n return <div />;\n}\n');
write(
'web/src/forms/profile-form.tsx',
`import { z } from 'zod';

const User = z.object({ name: z.string() });
type User = z.infer<typeof User>;

export function ProfileForm({ user }: { user: User }) {
return <form>{user.name}</form>;
}
`
);
await index();
const referenced = cg.db.db
.prepare(
`SELECT DISTINCT t.file_path AS f FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = 'ProfileForm' AND e.kind = 'references'`
)
.all()
.map((row: any) => row.f);
expect(referenced).toEqual(['web/src/forms/profile-form.tsx']);
});
});
51 changes: 51 additions & 0 deletions __tests__/react-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1755,3 +1755,54 @@ export function About() {
expect({ bindings: routeBindings(cg), navigations: navigations(cg) }).toEqual(await fresh(false));
}, 60_000);
});

describe('react-router: a layout the route file loads lazily', () => {
let root: string;
let cg: CodeGraph;
beforeAll(async () => {
({ root, cg } = await indexProject({
'package.json': JSON.stringify({ name: 'mantis', private: true }),
'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^7' } }),
'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom';
import MainRoutes from './MainRoutes';

const router = createBrowserRouter([MainRoutes]);

export default router;
`,
'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react';
import Loadable from '../components/Loadable';

const MainLayout = Loadable(lazy(() => import('../layout/MainLayout')));
const Dashboard = Loadable(lazy(() => import('../views/Dashboard')));

const MainRoutes = {
path: '/',
element: <MainLayout />,
children: [
{
path: 'dashboard',
element: <Dashboard />
}
]
};

export default MainRoutes;
`,
'vite/src/layout/MainLayout/index.jsx': 'export default function Layout() {\n return <main />;\n}\n',
'vite/src/views/Dashboard.jsx': 'export default function Dashboard() {\n return <div />;\n}\n',
'vite/src/components/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n',
// The repository's other app, with a layout of the same name.
'next/package.json': JSON.stringify({ name: 'next-app', dependencies: { next: '^15', react: '^18' } }),
'next/src/layout/MainLayout.jsx': 'export default function MainLayout({ children }) {\n return <main>{children}</main>;\n}\n',
}));
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

it('binds the layout to the module the route file loads, never to a same-named layout of another app', () => {
expect(routeBindings(cg)).toEqual(['/ -> Layout', '/dashboard -> Dashboard', '/dashboard ~> Layout']);
});
});
3 changes: 2 additions & 1 deletion __tests__/sync-lazy-route-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ describe('sync redraws a route that renders a same-file lazy value when its modu
// module it loads leads to it.
'/docs': ['src/pages/Docs.tsx::DocsPage'],
});
expect(renders(cg!)).toEqual(['App -> src/pages/Team.tsx::Team']);
// A tag naming a lazy value renders the page that value loads, named like it or not.
expect(renders(cg!)).toEqual(['App -> src/pages/Docs.tsx::DocsPage', 'App -> src/pages/Team.tsx::Team']);
expect(cg!.getPendingReferenceCount()).toBe(0);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
Expand Down
Loading