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 @@ -114,6 +114,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like `<Team />`, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused.
- 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. 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.

## [1.6.2] - 2026-10-03

Expand Down
169 changes: 166 additions & 3 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,12 @@ export const Table = <Entry extends BaseEntity>({ data }: { data: Entry[] }) =>
);
await index();
// `Badge` is written as a type argument too, but a tag of it is enough.
// A generic tag's own name is not read at all: no tag ends after
// `<PaginatedList` (its type arguments follow).
expect(renders('DocumentList')).toEqual(['Badge app/components/Badge.tsx:2']);
// `<PaginatedList<Document>` is a tag that passes a type argument: it
// renders PaginatedList, and its `Document` stays a type.
expect(renders('DocumentList')).toEqual([
'Badge app/components/Badge.tsx:2',
'PaginatedList app/components/PaginatedList.tsx:1',
]);
expect(renders('Table')).toEqual([]);
});

Expand Down Expand Up @@ -338,3 +341,163 @@ export function Toolbar({ actions }) {
expect(renders('ExampleApp')).toEqual(['WelcomeScreen packages/excalidraw/components/WelcomeScreen.tsx:1']);
});
});

/**
* A generic component's tag passes its type arguments right after its name:
* outline's lists render `<PaginatedList<Document> items={…} />`, excalidraw's
* property panels `<RadioSelection<TextAlign | false> …>`. The pass read a
* tag's name only where whitespace, `/` or `>` ended it, so a tag that `<`
* ends was never read, and the parent had no edge to the component it renders.
* The type arguments stay types.
*/
describe('JSX child: a tag that passes type arguments', () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-generic-'));
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();
}

/** `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('renders the component a generic tag names, however its type arguments are written', async () => {
write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n');
write('app/models/Mode.ts', 'export class Mode {\n name = "";\n}\n');
write(
'app/components/PaginatedList.tsx',
`type PaginatedItem = { id: string };

const PaginatedList = <T extends PaginatedItem>({ items }: { items: T[] }) => {
return <ul>{items.length}</ul>;
};

export default PaginatedList;
`
);
write(
'app/components/RadioSelection.tsx',
'export const RadioSelection = <T extends Object>({ value }: { value: T }) => {\n return <div>{String(value)}</div>;\n};\n'
);
write(
'app/components/DataTable.tsx',
'export function DataTable<Row, Column>({ children }: { rows: Row[]; columns: Column[]; children?: unknown }) {\n return <table>{children}</table>;\n}\n'
);
write(
'app/components/DocumentList.tsx',
`import PaginatedList from './PaginatedList';
import { RadioSelection } from './RadioSelection';
import { DataTable } from './DataTable';
import Document from '../models/Document';
import { Mode } from '../models/Mode';

export function DocumentList({ documents, mode }: { documents: Document[]; mode: Mode }) {
return (
<section>
<PaginatedList<Document>
items={documents}
/>
<RadioSelection<Exclude<Mode, "auto"> | false> value={mode} />
<DataTable<Document, string> rows={documents} columns={[]}>
<p>{documents.length}</p>
</DataTable>
</section>
);
}
`
);
await index();
// `Document` and `Mode` are classes the tags pass as types, not children.
expect(renders('DocumentList')).toEqual([
'DataTable app/components/DataTable.tsx:1',
'PaginatedList app/components/PaginatedList.tsx:3',
'RadioSelection app/components/RadioSelection.tsx:1',
]);
});

it('still renders nothing for a generic type written as a type argument', async () => {
write(
'app/components/Grid.tsx',
`import * as React from 'react';

export class Grid<Row> extends React.Component<{ rows: Row[] }> {
render() {
return <table>{this.props.rows.length}</table>;
}
}
`
);
write(
'app/components/Report.tsx',
`import * as React from 'react';
import { Grid } from './Grid';

export function Report({ rows }: { rows: string[] }) {
const grid = React.useRef<Grid<string>>(null);
const [grids] = React.useState<Array<Grid<string>>>([]);
return <section>{rows.length + grids.length}</section>;
}
`
);
await index();
expect(renders('Report')).toEqual([]);
});

it('renders nothing for a generic tag the file imports from a package', async () => {
// refine's examples: react95's `<Select<number>>` and antd's
// `<Table<IPost>>` are the packages' own components, not another app's
// Select or Table that shares the name.
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"react":"^18.0.0","react95":"^4.0.0","antd":"^5.0.0"}}'
);
write('apps/shadcn/components/ui/select.tsx', 'export function Select() {\n return <select />;\n}\n');
write('apps/shadcn/components/ui/table.tsx', 'export function Table() {\n return <table />;\n}\n');
write(
'apps/win95/pages/posts.tsx',
`import { Select } from 'react95';
import { Table } from 'antd';

type IPost = { id: number };

export function PostList({ posts }: { posts: IPost[] }) {
return (
<div>
<Select<number> options={[]} />
<Table<IPost> dataSource={posts} />
</div>
);
}
`
);
await index();
expect(renders('PostList')).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion __tests__/jsx-render-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as path from 'path';
import { CodeGraph } from '../src';

/** The source of the pass's tag pattern, to count its scans by. */
const JSX_TAG_SOURCE = String.raw`<([A-Z][A-Za-z0-9_]*)[\s/>]`;
const JSX_TAG_SOURCE = String.raw`<([A-Z][A-Za-z0-9_]*)(?=[\s/><])`;

describe('jsx-render pass work', () => {
let tmpDir: string | undefined;
Expand Down
6 changes: 5 additions & 1 deletion src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
const SETSTATE_RE = /this\.setState\s*\(/;
const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
// A tag's name ends at whitespace, `/` or `>`, or at the `<` of the type
// arguments a generic component's tag passes: `<PaginatedList<Document>
// items={…} />` renders PaginatedList. The lookahead leaves that `<` to open
// the next match, which `opensTag` reads as a type argument, not a tag.
const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g;
const MAX_JSX_CHILDREN = 30;
// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
Expand Down