Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});

/**
Expand Down
73 changes: 69 additions & 4 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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}`;
Expand Down