fix(react): a type argument or a name the component binds itself renders no JSX child (#2442)

The jsx-render pass reads tag names off a parent's source with a pattern,
and two kinds of name it read that way linked a same-named class or
component elsewhere:

- A name written only in a type argument or type parameter list:
  outline's `<PaginatedList<Document> …>` and `useState<User>()`, or
  bulletproof-react's generic `<Entry extends BaseEntity>(…) =>`. A `<Name`
  right after an identifier opens a type argument list (the rule #2420
  applies to the values a file declares, here for every name), and a
  `<Name extends …` opens a type parameter list.
- A name the parent binds itself by its first tag, a `const`/`let`/`var` or
  a parameter (name-matcher's `jsCodeBindsName`, now exported): outline's
  `const Content = variant === "dropdown" ? DropdownMenu.SubContent :
  ContextMenu.SubContent`, `(Widget, index) => <Widget />`. Such a tag
  renders only a component the parent declares inside itself, else
  nothing. The check runs only for names the parent also writes outside a
  tag. Destructured names still link by name, as for a destructured call.

Fresh before/after indexes on 13 repos (main ed199e60): 66 jsx-render
edges removed and none added; no other edge, node, ref or file changes.
Every removal checks out against the TypeScript compiler (38 names with
no JSX tag in the parent, 28 tags bound to a local); 64 of the 66 targets
were wrong, 2 right by luck. mantis, proshop_mern, next-saas-starter and
create-t3-turbo are byte-identical, and Screens is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 14:42:43 +00:00
committed by GitHub
parent ed199e60d8
commit 4731149469
4 changed files with 301 additions and 24 deletions
+1
View File
@@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374)
- React Router routes written as JSX now read `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `<Suspense fallback={<Loader />}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
## [1.6.2] - 2026-10-03
+209
View File
@@ -129,3 +129,212 @@ export function Header() { return <h1><Badge /></h1>; }
expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']);
});
});
/**
* The pass reads tag names off a parent's source with a pattern, and two
* kinds of name it reads that way are no other file's component.
*
* - A type argument: `<PaginatedList<Document> …>` or `useState<User>()`
* writes a type's name right after another name. outline's document list
* rendered its `Document` model class, its settings tables the `User` and
* `Group` classes.
* - A name the parent binds itself, as a `const` or a parameter, is that local
* wherever its tags sit. outline's menus pick a Radix part into `const
* Content = variant === 'dropdown' ? DropdownMenu.SubContent :
* ContextMenu.SubContent` and rendered the command bar's styled `Content`
* instead. A local is no node, so it renders nothing, unless the parent
* declares a component of that name inside itself.
*/
describe('JSX child: a name the parent writes only as a type, or binds itself', () => {
let dir: string;
let cg: any;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-local-'));
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 nothing for a name written only as a type argument or parameter', async () => {
write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n');
write('app/models/User.ts', 'export default class User {\n name = "";\n}\n');
write(
'app/components/PaginatedList.tsx',
'export function PaginatedList<T>({ items }: { items: T[] }) {\n return <ul>{items.length}</ul>;\n}\n'
);
write(
'app/components/Badge.tsx',
'import * as React from "react";\nexport class Badge extends React.Component {\n render() {\n return <b />;\n }\n}\n'
);
write(
'app/components/DocumentList.tsx',
`import * as React from 'react';
import Document from '../models/Document';
import User from '../models/User';
import { Badge } from './Badge';
import { PaginatedList } from './PaginatedList';
export function DocumentList({ items }: { items: Document[] }) {
const [owner] = React.useState<User | null>(null);
const badge = React.useRef<Badge>(null);
return (
<div>
<PaginatedList<Document> items={items} />
<Badge ref={badge} />
</div>
);
}
`
);
// bulletproof-react's table: a generic arrow function's type parameter.
write('app/profile/entry.tsx', 'export function Entry() {\n return <div />;\n}\n');
write(
'app/components/Table.tsx',
`type BaseEntity = { id: string };
export const Table = <Entry extends BaseEntity>({ data }: { data: Entry[] }) => {
return <table>{data.length}</table>;
};
`
);
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']);
expect(renders('Table')).toEqual([]);
});
it('renders nothing for a name the parent binds itself as a const or a parameter', async () => {
write(
'app/components/CommandBarItem.tsx',
'export function Content() {\n return <div />;\n}\n\nexport function Icon() {\n return <svg />;\n}\n'
);
write('shared/editor/Widget.tsx', 'export function Widget() {\n return <div />;\n}\n');
write('app/components/Portal.tsx', 'export function Portal({ children }) {\n return <div>{children}</div>;\n}\n');
write(
'app/components/Menu.tsx',
`import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as ContextMenu from '@radix-ui/react-context-menu';
const Portal = DropdownMenu.Portal;
export function SubMenuContent({ variant, children }) {
const Portal =
variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal;
const Content =
variant === 'dropdown'
? DropdownMenu.SubContent
: ContextMenu.SubContent;
return (
<Portal>
<Content collisionPadding={6}>{children}</Content>
</Portal>
);
}
export function SettingsLinks({ config }) {
return (
<ul>
{config.map((item) => {
const Icon = item.icon;
return <li key={item.path}><Icon /></li>;
})}
</ul>
);
}
export function Widgets({ widgets }) {
return <div>{Object.values(widgets).map((Widget, index) => <Widget key={index} />)}</div>;
}
`
);
await index();
// The file's own `Portal` is shadowed too: the tag means the local.
expect(renders('SubMenuContent')).toEqual([]);
expect(renders('SettingsLinks')).toEqual([]);
expect(renders('Widgets')).toEqual([]);
});
it('renders a component the parent declares inside itself, and the outer one for a tag above the local', async () => {
write('a-decoy/row.tsx', 'export function Row() {\n return <tr />;\n}\n');
write('app/icon.tsx', 'export function Icon() {\n return <svg />;\n}\n');
write(
'app/table.tsx',
`import { Icon } from './icon';
function Row() {
return <tr className="outer" />;
}
export function Table({ rows }) {
const Row = ({ row }) => <tr>{row.name}</tr>;
return <table>{rows.map((row) => <Row key={row.id} row={row} />)}</table>;
}
export function Toolbar({ actions }) {
return (
<div>
<Icon />
{actions.map((action) => {
const Icon = action.icon;
return <Icon key={action.id} />;
})}
</div>
);
}
`
);
await index();
expect(renders('Table')).toEqual(['Row app/table.tsx:8']);
expect(renders('Toolbar')).toEqual(['Icon app/icon.tsx:1']);
});
it('keeps a destructured name on the same-named component, as a destructured call does', async () => {
// excalidraw's example app: `const { Sidebar, Footer, … } = excalidrawLib`.
// Destructuring is not the parent's own binding here, as it is not for a
// bare call (`const { t } = useI18n()`), so the name links by name.
write(
'packages/excalidraw/components/WelcomeScreen.tsx',
'export default function WelcomeScreen() {\n return <div />;\n}\n'
);
write(
'examples/app/ExampleApp.tsx',
`export default function ExampleApp({ excalidrawLib }) {
const { WelcomeScreen, exportToSvg } = excalidrawLib;
return <div><WelcomeScreen /></div>;
}
`
);
await index();
expect(renders('ExampleApp')).toEqual(['WelcomeScreen packages/excalidraw/components/WelcomeScreen.tsx:1']);
});
});
+72 -9
View File
@@ -39,7 +39,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield';
import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer';
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary } from './name-matcher';
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -1584,14 +1584,62 @@ function jsxChild(
return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0];
}
/**
* Whether the `<Name` at `at` opens a JSX tag. A type argument list follows
* its type's name directly — `useState<User>()`, `Array<Item>`, the
* `<Document>` of `<PaginatedList<Document>` — and a tag never does, short of
* a `return` written up against it. A generic arrow function's type
* parameters read like a tag but constrain theirs: `<Entry extends
* BaseEntity>({ data }) =>`.
*/
function opensTag(src: string, at: number, name: string): boolean {
const end = at + 1 + name.length;
if (/^\s+extends\s/.test(src.slice(end, end + 40))) return false;
return !/[\w$]/.test(src[at - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, at - 7), at));
}
/**
* Whether `src` writes `name` other than in a tag (`<Name`, `</Name`): a
* parent binds a name only where it writes it bare (`const Content =`,
* `(Widget) =>`), so the check for that runs only on these.
*/
function writtenBare(src: string, name: string): boolean {
for (let at = src.indexOf(name); at !== -1; at = src.indexOf(name, at + name.length)) {
if (/[\w$]/.test(src[at - 1] ?? '') || /[\w$]/.test(src[at + name.length] ?? '')) continue;
if (src[at - 1] === '<' || (src[at - 1] === '/' && src[at - 2] === '<')) continue;
return true;
}
return false;
}
/**
* What a tag renders when its parent binds the name itself: a component the
* parent declares in its own lines (`const Row = ({ row }) => <tr />` in a
* table's body), else nothing. Any other local is no node, and a same-named
* component elsewhere is only a guess at what it holds.
*/
function declaredInside(ctx: ResolutionContext, name: string, parent: Node, child: Node): Node | undefined {
const inside = (n: Node) => n.id !== parent.id && n.filePath === parent.filePath &&
n.startLine >= parent.startLine && n.startLine <= parent.endLine;
if (inside(child)) return child;
return ctx.getNodesByName(name).find((n) => JSX_CHILD_KINDS.has(n.kind) && inside(n));
}
/**
* Phase 5: React JSX child rendering. A component that returns `<Child .../>`
* mounts Child — React calls it — but JSX instantiation isn't a static call edge,
* so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
* JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
* (read each JSX file once). Precision gate: the child name must resolve to a
* component/function/class node — TS generics like `Array<Foo>` resolve to a type
* (or nothing) and are dropped.
* (read each JSX file once). Precision gates: the parent must write the name as
* a tag, not only in a type argument or parameter list (`useState<User>()`,
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
* read its `Document` model class as a child), and the name must resolve to a
* component/function/class node. A name the parent binds itself by its first
* tag — a parameter, or a `const` like outline's `const Content = variant ===
* "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that
* local, and renders only a component declared inside the parent
* (`declaredInside`). A destructured name still links by name, as a
* destructured function call does.
*/
async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
let scannedFiles = 0;
@@ -1620,26 +1668,41 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
// file is split once, not once per function.
let lines: string[] | null = null;
const tagsBySpan = new Map<string, Set<string>>();
// Each name a span holds, and the line of its first tag: 0 while only type
// argument or parameter lists name it (`opensTag`). `bare` holds the
// names it also writes outside a tag (`writtenBare`).
const tagsBySpan = new Map<string, { tags: Map<string, number>; bare: Set<string> }>();
for (const parent of parents) {
if (!parent.startLine || !parent.endLine) continue;
const span = `${parent.startLine}:${parent.endLine}`;
let names = tagsBySpan.get(span);
if (!names) {
names = new Set<string>();
names = { tags: new Map<string, number>(), bare: new Set<string>() };
lines ??= content.split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('</') || src.includes('/>')) {
let line = parent.startLine;
let counted = 0;
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
while ((m = JSX_TAG_RE.exec(src))) {
for (let nl = src.indexOf('\n', counted); nl !== -1 && nl < m.index; nl = src.indexOf('\n', nl + 1)) line++;
counted = m.index;
if (!names.tags.get(m[1]!)) names.tags.set(m[1]!, opensTag(src, m.index, m[1]!) ? line : 0);
}
for (const [name, first] of names.tags) if (first && writtenBare(src, name)) names.bare.add(name);
}
tagsBySpan.set(span, names);
}
let added = 0;
for (const name of names) {
for (const [name, line] of names.tags) {
if (added >= MAX_JSX_CHILDREN) break;
const child = jsxChild(ctx, name, file, importsOf);
if (!line) continue;
let child = jsxChild(ctx, name, file, importsOf);
// A name the parent binds itself by its first tag is that local.
if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) {
child = declaredInside(ctx, name, parent, child);
}
if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue;
const key = `${parent.id}>${child.id}`;
if (seen.has(key)) continue;
+19 -15
View File
@@ -6857,7 +6857,7 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut
let scope: { start: number; end: number } | null = null;
const fn = context.getNodeById?.(ref.fromNodeId);
if (fn && (fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line &&
jsCodeBindsName(name, fn, ref, context)) {
jsCodeBindsName(name, fn, ref.filePath, ref.line, context)) {
scope = { start: fn.startLine, end: fn.endLine };
}
memo.set(key, scope);
@@ -6872,14 +6872,18 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut
*/
const JS_CODE_BINDS = new WeakMap<ResolutionContext, Map<string, boolean>>();
/** Whether the function's code through the reference's line declares `name`, or names it in a parameter list. */
function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
/**
* Whether the function's code in `filePath`, from its first line through
* `line` (a reference's, or a JSX tag's), declares `name`, or names it in a
* parameter list.
*/
export function jsCodeBindsName(name: string, fn: Node, filePath: string, line: number, context: ResolutionContext): boolean {
let memo = JS_CODE_BINDS.get(context);
if (!memo) JS_CODE_BINDS.set(context, (memo = new Map()));
const key = `${ref.filePath}\0${fn.startLine}\0${ref.line}\0${name}`;
const key = `${filePath}\0${fn.startLine}\0${line}\0${name}`;
let binds = memo.get(key);
if (binds === undefined) {
const text = jsFunctionCodeThrough(fn, ref, context);
const text = jsFunctionCodeThrough(fn, filePath, line, context);
const { param } = localBindingPatterns(name, 'g');
const n = name.replace(/\$/g, '\\$');
// A plain declaration. Destructuring re-binds what a call returns under
@@ -6904,38 +6908,38 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re
}
/**
* Per context: the comment-stripped lines of the functions jsFunctionLocalScope
* Per context: the comment-stripped lines of the functions jsCodeBindsName
* read last, and where each line ends in that text, most recent last.
*/
const JS_FN_CODE = new WeakMap<ResolutionContext, Map<string, { code: string; lineEnds: number[] }>>();
const JS_FN_CODE_KEEP = 64;
/**
* The function's lines from its first through the reference's, comments
* blanked. Stripping looks at most one character ahead — past a line's end,
* a newline, which completes no comment marker — so this is the whole
* The function's lines from its first through `line`, comments blanked.
* Stripping looks at most one character ahead — past a line's end, a
* newline, which completes no comment marker — so this is the whole
* function's stripped text cut at that line's end. Stripping the lines again
* for every reference took time in the square of a large function's length;
* in a minified script, every function's text runs to the end of its one
* line (#2334).
*/
function jsFunctionCodeThrough(fn: Node, ref: UnresolvedRef, context: ResolutionContext): string {
function jsFunctionCodeThrough(fn: Node, filePath: string, line: number, context: ResolutionContext): string {
let fns = JS_FN_CODE.get(context);
if (!fns) JS_FN_CODE.set(context, (fns = new Map()));
const key = `${ref.filePath}\0${fn.startLine}\0${fn.endLine}`;
const key = `${filePath}\0${fn.startLine}\0${fn.endLine}`;
let own = fns.get(key);
if (own) {
fns.delete(key);
} else {
const lines = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? [])
const lines = (context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? [])
.slice(fn.startLine - 1, fn.endLine);
let end = -1;
own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((line) => (end += line.length + 1)) };
own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((text) => (end += text.length + 1)) };
if (fns.size >= JS_FN_CODE_KEEP) fns.delete(fns.keys().next().value!);
}
fns.set(key, own);
// Through the reference's line, or the file's last when it ends sooner.
const last = Math.min(ref.line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine;
// Through `line`, or the file's last when it ends sooner.
const last = Math.min(line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine;
return last < 0 ? '' : own.code.slice(0, own.lineEnds[last]);
}