Files
Colby McHenry 64426cad93 fix(react): recognize forwardRef/memo/styled components + index JSX-file routes (#841)
forwardRef/memo/styled-wrapped component consts were classified as plain
`constant` nodes (the initializer is a call/tagged-template, not a bare arrow),
so the JSX-render synthesizer and component resolution skipped them — callers
and impact returned empty for the entire shadcn/ui-style UI layer. Recognize
them in the tree-sitter extractor as `component` nodes (correct body range +
callee capture), PascalCase-gated so a memoization util stays a constant.

Separately, the `react` resolver's `languages` lacked 'tsx'/'jsx', so its
`extract()` never ran on JSX files — React Router `<Route>`/createBrowserRouter
and Next.js page routes (which only live in .tsx/.jsx) were never indexed. Add
'tsx'/'jsx' and make `extract()` route-only: the component/hook regex it carried
duplicated tree-sitter nodes (a `useAuth` became two `function` nodes) and is
fully superseded by the extractor now.

Validated before/after: taxonomy 0->99 component nodes (35 w/ callers) + 1->15
routes; radix 0->262 components (80 w/ callers); cypress-realworld-app 45->52
routes (7 <Route> tags from .tsx); non-React control unchanged; node count
stable. New tests: react-hoc-component.test.ts + a route e2e in
frameworks-integration.test.ts.

Root-caused by @maxmilian (#846); reported by @Arlandaren.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:35:12 -05:00

146 lines
5.3 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* #841 — React components declared via an HOC wrapper
* (`const Button = forwardRef(...)`, `memo(...)`, `styled.x\`…\``) were indexed
* as plain `constant` nodes, so their JSX usages (`<Button/>`) got no render
* edge and `getCallers` / `getImpactRadius` returned empty — a dangerous silent
* false negative for every shadcn/ui-style design system. They must now be
* `component` nodes that receive jsx-render edges like function components do.
*/
describe('React HOC-wrapped component recognition (#841)', () => {
let dir: string;
let cg: any;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-hoc-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
});
afterEach(() => {
cg?.close?.();
fs.rmSync(dir, { recursive: true, force: true });
});
async function index() {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
return (cg as any).db.db;
}
const kindsOf = (db: any, name: string): string[] =>
db
.prepare('SELECT kind FROM nodes WHERE name=? ORDER BY kind')
.all(name)
.map((r: any) => r.kind);
it('classifies forwardRef / memo / styled consts as component nodes (not constant)', async () => {
fs.writeFileSync(
path.join(dir, 'ui.tsx'),
`import * as React from 'react';
import styled from 'styled-components';
export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
export const Bare = forwardRef((props, ref) => <span ref={ref} />);
export const Card = memo((props: { t: string }) => <div>{props.t}</div>);
export const Named = memo(function Named(props: { t: string }) { return <div>{props.t}</div>; });
export const Boxed = styled.div\`color: red;\`;
export const Wrapped = styled(Button)\`padding: 4px;\`;
export const Rewrapped = memo(Button);
`
);
const db = await index();
for (const name of ['Button', 'Bare', 'Card', 'Named', 'Boxed', 'Wrapped', 'Rewrapped']) {
expect(kindsOf(db, name), `${name} should be a component`).toContain('component');
// The bug was that these stayed plain constants.
expect(kindsOf(db, name), `${name} should not remain a constant`).not.toContain('constant');
}
});
it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => {
fs.writeFileSync(
path.join(dir, 'button.tsx'),
`import * as React from 'react';
export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
`
);
fs.writeFileSync(
path.join(dir, 'page.tsx'),
`import { Button } from './button';
export function Page() {
return <Button>Click</Button>;
}
`
);
const db = await index();
// The render edge exists and is the synthesized jsx-render kind.
const edgeRows = db
.prepare(
`SELECT s.name caller FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
AND t.kind = 'component' AND t.name = 'Button'`
)
.all();
expect(edgeRows.map((r: any) => r.caller)).toContain('Page');
// ...and it surfaces through the public callers API (the issue's symptom:
// "No callers found" before the fix).
const buttonId = db
.prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'")
.get().id as string;
const callers = cg.getCallers(buttonId).map((c: any) => c.node.name);
expect(callers).toContain('Page');
});
it('captures the inner render-fn body callees under the component', async () => {
fs.writeFileSync(
path.join(dir, 'widget.tsx'),
`import * as React from 'react';
function useThing() { return 1; }
export const Widget = React.forwardRef((props, ref) => {
const v = useThing();
return <div ref={ref}>{v}</div>;
});
`
);
const db = await index();
const rows = db
.prepare(
`SELECT t.name FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = 'Widget' AND s.kind = 'component'
AND e.kind = 'calls' AND t.name = 'useThing'`
)
.all();
expect(rows.length).toBeGreaterThanOrEqual(1);
});
it('does not misclassify non-component PascalCase consts (precision)', async () => {
fs.writeFileSync(
path.join(dir, 'controls.tsx'),
`import * as React from 'react';
const cache = memo(expensiveFn);
export const Config = loadConfig();
export const Client = new ApiClient();
export const Styles = styledHelper();
export const Total = [1, 2].reduce((a, b) => a + b, 0);
export const Theme = { color: 'red' };
`
);
const db = await index();
for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) {
expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant');
expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component');
}
// A lowercase-named memo() result is a memoization util, not a component.
expect(kindsOf(db, 'cache')).not.toContain('component');
});
});