fix(react): a tag that passes type arguments renders its generic component (#2476)

The jsx-render pass read a tag's name only where whitespace, `/` or `>`
ended it, so a generic component's tag, whose type arguments follow its
name, was never read: outline's `<PaginatedList<Document> items={…} />`
and excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap">`
left the parent with no edge to the component it renders. The tag pattern
now also ends a name at `<`, as a lookahead, so that `<` still opens the
next match, which `opensTag` (#2442) reads as a type argument, not a tag.

This lands after the package-import gate for JSX tags (#2475). On main alone the
same change also linked 5 package tags to project namesakes by name
(refine's react95 `<Select<number>>`, antd `<Table<IPost>>` and
`<Form<ILoginForm>>`; signoz's antd `<Form<…>>` ×2 to a styled `Form`),
which the gate declines.

Fresh before/after indexes with the gate as the base: 22 jsx-render edges
added and nothing else changed (outline 10 to `PaginatedList`, signoz 8,
excalidraw 2, mastodon 2). Each target is what the file's import or own
declaration binds, checked with the TypeScript checker with module
resolution. refine-examples, bulletproof-react, mantis, proshop_mern,
next-saas-starter and create-t3-turbo are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-10 05:56:32 +00:00
committed by GitHub
parent 34183992ac
commit 0951105538
4 changed files with 173 additions and 5 deletions
+1
View File
@@ -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
+166 -3
View File
@@ -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([]);
});
@@ -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([]);
});
});
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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/>)