mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/>)
|
||||
|
||||
Reference in New Issue
Block a user