mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
fix(react): a name the file destructures at its top renders no JSX child (#2677)
Since PR 2676, a tag whose name the parent, or a function around it,
destructures renders only a component that function declares inside
itself. A name the file destructures at its top was left out, because no
function's code holds it: refine's pages take `const { Title, Text } =
Typography;` from antd once, above their components, and every `<Title>`
and `<Text>` in them linked to another example's `Title` or `Text`
component by name.
The pass now reads the destructuring patterns the file declares outside
every brace (`topLevelPatterns`, on the code-only view), so in no
function, class or block, and never a top-level callback's parameter.
Only braces are counted: JSX text may hold a stray parenthesis or
bracket, never a brace that opens no expression, and `jsxCode` returns
null for a file whose braces don't balance, which then binds nothing
here. A tag that no function in its scope chain binds, but that such a
pattern binds (`bindingElementAt`, so not a `require(...)` or `import(...)`
declaration), renders only a component the outermost of those functions
declares inside itself, else nothing.
Fresh before/after indexes on 31 repos (main 24ea3c54): 177 jsx-render
edges removed, all in refine-examples (94 to one example's `Text`, 83 to
another's `Title`), and every tag of each is a module-level destructured
local by the TypeScript checker. Nothing else changes; 30 repos are
byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- 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.
|
||||
- In React, a tag for a component a function destructures from its props or from an object, like `({ Header, Footer, children }) =>` in refine's layouts or `const { Sidebar, Footer } = excalidrawLib` in excalidraw's example app, now links to nothing, as a tag naming a variable the function sets itself already did, unless the function declares a component of that name inside itself. A function nested in the one that sets or destructures the name, like a `renderSidebar` helper inside the component, reads the tag the same way. A name destructured from `require(…)` or `import(…)` still links to that module's export. Before, these tags were matched by their name alone, so refine's layouts showed up among the callers of other examples' `Header` and `Footer` components, and `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
|
||||
- In React, a tag for a component a file destructures at its top, like `const { Title, Text } = Typography;` with antd's `Typography`, as in refine's pages, now links to nothing in every component of that file, unless the component declares one of that name inside itself. A name destructured from `require(…)` there still links to that module's export. Before, these tags were matched by their name alone, so refine's pages showed up among the callers of other examples' `Title` and `Text` components. Re-index React projects after upgrading.
|
||||
- In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* <Progress /> */}`, or an error message like react-router's "You cannot render a <Router> inside another <Router>" or shadcn's "useCarousel must be used within a <Carousel />". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `<p>Don't have an account? <Link to="/signup">`, still links as before. Re-index React projects after upgrading.
|
||||
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
|
||||
- In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading.
|
||||
|
||||
@@ -484,6 +484,62 @@ module.exports = { LegacyPage, renderChart };
|
||||
expect(renders('LegacyPage')).toEqual(['Legacy app/legacy.tsx:1']);
|
||||
expect(renders('renderChart')).toEqual(['Chart app/chart.tsx:1']);
|
||||
});
|
||||
|
||||
it('renders nothing for a name the file destructures at its top, unless a function declares it inside itself', async () => {
|
||||
write('examples/finefoods/title.tsx', 'export function Title() {\n return <h1 />;\n}\n');
|
||||
write('examples/crm/text.tsx', 'export function Text() {\n return <span />;\n}\n');
|
||||
// refine's pages take antd's typography parts once, at the top.
|
||||
write(
|
||||
'examples/blog/show.tsx',
|
||||
`import { Typography } from 'antd';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export function PostShow({ post }) {
|
||||
return (
|
||||
<div>
|
||||
<Title level={5}>Title</Title>
|
||||
<Text>{post.title}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Summary() {
|
||||
function Text({ children }) {
|
||||
return <p>{children}</p>;
|
||||
}
|
||||
return <Text>summary</Text>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('PostShow')).toEqual([]);
|
||||
expect(renders('Summary')).toEqual(['Text examples/blog/show.tsx:15']);
|
||||
});
|
||||
|
||||
it('still renders the component for a top-level require, a top-level callback’s parameter or another function’s local', async () => {
|
||||
write('app/legacy.tsx', 'export function Legacy() {\n return <div />;\n}\n');
|
||||
write('app/badge.tsx', 'export function Badge() {\n return <b />;\n}\n');
|
||||
write('app/chart.tsx', 'export function Chart() {\n return <canvas />;\n}\n');
|
||||
write(
|
||||
'app/page.jsx',
|
||||
`const { Legacy } = require('./legacy');
|
||||
|
||||
[1].forEach(({ Badge }) => Badge);
|
||||
|
||||
function helper(props) {
|
||||
const { Chart } = props;
|
||||
return Chart;
|
||||
}
|
||||
|
||||
export function Page() {
|
||||
return <div><Legacy /><Badge /><Chart />{helper}</div>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('Page')).toEqual(['Badge app/badge.tsx:1', 'Chart app/chart.tsx:1', 'Legacy app/legacy.tsx:1']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2023,6 +2023,47 @@ function destructures(src: string, name: string, end: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Where a destructuring declaration's pattern opens: `const {`, `let [`. */
|
||||
const DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*[{[]/g;
|
||||
|
||||
/**
|
||||
* The patterns `code` destructures at its top, as [opener, closer] pairs: a
|
||||
* declaration's outside every brace, so in no function, class or block, and
|
||||
* never a top-level callback's parameter. Braces alone are counted: JSX text
|
||||
* may hold a stray `(` or `[`, never a `{` that opens no expression, and
|
||||
* `code` (`jsxCode`) balances them.
|
||||
*/
|
||||
function topLevelPatterns(code: string): Array<[number, number]> {
|
||||
const patterns: Array<[number, number]> = [];
|
||||
let depth = 0;
|
||||
let at = 0;
|
||||
DESTRUCTURING_DECLARATION.lastIndex = 0;
|
||||
for (let m = DESTRUCTURING_DECLARATION.exec(code); m; m = DESTRUCTURING_DECLARATION.exec(code)) {
|
||||
for (; at < m.index; at++) {
|
||||
if (code[at] === '{') depth++;
|
||||
else if (code[at] === '}') depth--;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const close = matchingCloser(code, open);
|
||||
if (close > open) patterns.push([open, close]);
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one of `patterns` in `code` binds `name` (`bindingElementAt`), so
|
||||
* not in a declaration of `require(…)` or `import(…)`.
|
||||
*/
|
||||
function patternsBind(code: string, patterns: Array<[number, number]>, name: string): boolean {
|
||||
for (const [open, close] of patterns) {
|
||||
for (let at = code.indexOf(name, open); at !== -1 && at < close; at = code.indexOf(name, at + name.length)) {
|
||||
if (bindingElementAt(code, at, name.length)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the name at `at` is an element of a pattern that binds it — `{
|
||||
* Name }`, `{ ...Name }`, `{ key: Name }`, `[Name]` — followed by the next
|
||||
@@ -2165,9 +2206,11 @@ function skipSpace(code: string, at: number, step: 1 | -1): number {
|
||||
* `Layout({ children, Header, Footer, Sider })` rendered another example's
|
||||
* `Footer`, and excalidraw's example app rendered the `Sidebar` of its own
|
||||
* `ExampleSidebar.tsx` for the library's, out of `const { Sidebar, Footer } =
|
||||
* excalidrawLib`. A destructured name keeps linking for a bare call — `const
|
||||
* { t } = useI18n()` is that function more often than not — but a tag's is a
|
||||
* component the props or a library hand in, which is no node here.
|
||||
* excalidrawLib`. A name the file destructures at its top, like refine's
|
||||
* `const { Title, Text } = Typography`, is a local of every function in it
|
||||
* (`topLevelPatterns`). A destructured name keeps linking for a bare call —
|
||||
* `const { t } = useI18n()` is that function more often than not — but a
|
||||
* tag's is a component the props or a library hand in, which is no node here.
|
||||
*/
|
||||
async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
let scannedFiles = 0;
|
||||
@@ -2217,6 +2260,8 @@ 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, and read as code once: a
|
||||
// tag in a comment or a string is text (`jsxCode`).
|
||||
let code: string | null | undefined;
|
||||
const codeOf = () => (code === undefined ? (code = jsxCode(content)) : code);
|
||||
let lines: string[] | null = null;
|
||||
// Each name a span holds (`spanTags`), read when a function among the
|
||||
// parents is, or a function around one.
|
||||
@@ -2225,12 +2270,27 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
const span = `${fn.startLine}:${fn.endLine}`;
|
||||
let names = tagsBySpan.get(span);
|
||||
if (!names) {
|
||||
lines ??= (jsxCode(content) ?? content).split('\n');
|
||||
lines ??= (codeOf() ?? content).split('\n');
|
||||
names = spanTags(lines.slice(fn.startLine - 1, fn.endLine).join('\n'), fn.startLine);
|
||||
tagsBySpan.set(span, names);
|
||||
}
|
||||
return names;
|
||||
};
|
||||
// Whether the file destructures a name at its top (`topLevelPatterns`),
|
||||
// once per name. Read only off code: a file `jsxCode` can't read binds
|
||||
// nothing here.
|
||||
let topPatterns: Array<[number, number]> | null = null;
|
||||
const topBinds = new Map<string, boolean>();
|
||||
const destructuredAtTop = (name: string): boolean => {
|
||||
let binds = topBinds.get(name);
|
||||
if (binds === undefined) {
|
||||
const src = codeOf();
|
||||
topPatterns ??= src === null ? [] : topLevelPatterns(src);
|
||||
binds = src !== null && topPatterns.length > 0 && patternsBind(src, topPatterns, name);
|
||||
topBinds.set(name, binds);
|
||||
}
|
||||
return binds;
|
||||
};
|
||||
for (const parent of parents) {
|
||||
if (!parent.startLine || !parent.endLine) continue;
|
||||
const names = spanOf(parent);
|
||||
@@ -2258,6 +2318,11 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
}
|
||||
}
|
||||
if (binder) child = declaredInside(ctx, name, binder, child);
|
||||
// A name the file destructures at its top is every function's local:
|
||||
// refine's pages take `const { Title, Text } = Typography` from antd
|
||||
// and rendered two other examples' `Title` and `Text`. A function
|
||||
// that declares the name inside itself still renders its own.
|
||||
else if (destructuredAtTop(name)) child = declaredInside(ctx, name, around?.[around.length - 1] ?? parent, child);
|
||||
}
|
||||
if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue;
|
||||
const key = `${parent.id}>${child.id}`;
|
||||
|
||||
Reference in New Issue
Block a user