diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d7845d54..946bba27e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 ` `, 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 `` from antd, `` from `@mui/material/Typography`, ` ` from react-router-dom or `` 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 `` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `` and `` 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 ` items={…} />` or excalidraw's ` …>`, 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
diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts
index 2aedc99b5..34247df2a 100644
--- a/__tests__/jsx-child-disambiguation.test.ts
+++ b/__tests__/jsx-child-disambiguation.test.ts
@@ -227,9 +227,12 @@ export const Table = ({ 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
- // `` 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([]);
});
@@ -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 ` items={…} />`, excalidraw's
+ * property panels ` …>`. 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 {
+ 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 = ({ items }: { items: T[] }) => {
+ return ;
+};
+
+export default PaginatedList;
+`
+ );
+ write(
+ 'app/components/RadioSelection.tsx',
+ 'export const RadioSelection = ({ value }: { value: T }) => {\n return {String(value)}
;\n};\n'
+ );
+ write(
+ 'app/components/DataTable.tsx',
+ 'export function DataTable({ children }: { rows: Row[]; columns: Column[]; children?: unknown }) {\n return ;\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 (
+
+
+ items={documents}
+ />
+ | false> value={mode} />
+ rows={documents} columns={[]}>
+ {documents.length}
+
+
+ );
+}
+`
+ );
+ 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 extends React.Component<{ rows: Row[] }> {
+ render() {
+ return ;
+ }
+}
+`
+ );
+ write(
+ 'app/components/Report.tsx',
+ `import * as React from 'react';
+import { Grid } from './Grid';
+
+export function Report({ rows }: { rows: string[] }) {
+ const grid = React.useRef>(null);
+ const [grids] = React.useState>>([]);
+ return {rows.length + grids.length} ;
+}
+`
+ );
+ 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 `>` and antd's
+ // `>` 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 ;\n}\n');
+ write('apps/shadcn/components/ui/table.tsx', 'export function Table() {\n return ;\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 (
+
+
options={[]} />
+ dataSource={posts} />
+
+ );
+}
+`
+ );
+ await index();
+ expect(renders('PostList')).toEqual([]);
+ });
+});
diff --git a/__tests__/jsx-render-work.test.ts b/__tests__/jsx-render-work.test.ts
index 68f5ae4b8..dd12b0a09 100644
--- a/__tests__/jsx-render-work.test.ts
+++ b/__tests__/jsx-render-work.test.ts
@@ -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;
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index 33cc99e1c..ce41c3da9 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -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: `
+// 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 ( → ElButton) and
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children ( )