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>
This commit is contained in:
Colby McHenry
2026-06-21 11:35:12 -05:00
co-authored by Claude Opus 4.8
parent b5090cbad5
commit 64426cad93
5 changed files with 306 additions and 65 deletions
+89
View File
@@ -44,6 +44,10 @@ export { generateNodeId } from './tree-sitter-helpers';
*/
const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
/** React HOC callees whose result is itself a component — a PascalCase const
* initialized with one of these is a component, not a constant (#841). */
const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
/** Vue store collections whose object-literal members are the symbols an agent
* looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
* findable + readable (the foundation under any later dispatch-bridge synth). */
@@ -1421,6 +1425,71 @@ export class TreeSitterExtractor {
this.nodeStack.pop();
}
/**
* Detect a React component declared via an HOC wrapper whose result is itself a
* component: `forwardRef(...)`, `memo(...)`, `React.forwardRef/memo(...)`, and
* styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``. These
* initializers are a call / tagged-template (not a bare arrow), so the const is
* otherwise classified `constant` — and a constant is skipped by both the
* JSX-render edge synthesizer and component resolution, so `<Button/>` usages
* get no edge and callers/impact silently return empty (#841).
*
* Returns `{ inner }` — the inline render function to extract as the component
* body, or `null` when the wrapper has no inline function (`memo(Imported)`,
* `styled.button\`…\``) and only a bodyless component node is minted — or
* `undefined` when this initializer is not a recognized component wrapper.
*/
private reactComponentHoc(valueNode: SyntaxNode): { inner: SyntaxNode | null } | undefined {
if (valueNode.type !== 'call_expression') return undefined;
const callee = getChildByField(valueNode, 'function');
if (!callee) return undefined;
const calleeText = getNodeText(callee, this.source);
// styled-components / emotion: `styled.button\`…\`` / `styled(Base)\`…\``.
// tree-sitter models these tagged templates as a call_expression whose callee
// is the `styled.x` / `styled(Base)` tag (\b avoids matching `styledFoo`).
// No inline render fn — the argument is the CSS template.
if (/^styled\b/.test(calleeText)) return { inner: null };
// React HOCs: `forwardRef`/`memo`/`React.forwardRef`/`React.memo`.
if (!REACT_COMPONENT_HOCS.has(calleeText)) return undefined;
// The first arrow / function-expression argument is the render fn (if inline;
// `memo(Imported)` passes a bare identifier and has none).
const args = getChildByField(valueNode, 'arguments');
let inner: SyntaxNode | null = null;
if (args) {
for (let i = 0; i < args.namedChildCount; i++) {
const a = args.namedChild(i);
if (a && (a.type === 'arrow_function' || a.type === 'function_expression')) {
inner = a;
break;
}
}
}
return { inner };
}
/**
* Emit a `component` node for an HOC-wrapped React component declaration (see
* reactComponentHoc). Named by the declarator (`Button`) and located at it so
* the node range spans the body. When the wrapper has an inline render
* function, its body is walked so the component's callees (hooks, helpers) are
* captured under the component node — matching how a plain
* `const Foo = () => …` arrow component already behaves.
*/
private extractReactComponentNode(
name: string,
declarator: SyntaxNode,
innerFn: SyntaxNode | null,
extra: { docstring?: string; signature?: string; isExported?: boolean }
): void {
const compNode = this.createNode('component', name, declarator, extra);
if (!compNode || !innerFn || !this.extractor) return;
this.nodeStack.push(compNode.id);
const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
?? getChildByField(innerFn, this.extractor.bodyField);
if (body) this.visitFunctionBody(body, compNode.id);
this.nodeStack.pop();
}
/**
* Extract a class
*/
@@ -2316,6 +2385,26 @@ export class TreeSitterExtractor {
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
// React HOC-wrapped components (`forwardRef`/`memo`/`styled`) — see
// reactComponentHoc. The initializer is a call / tagged-template (not
// a bare arrow), so without this the const is a plain `constant`,
// which the JSX-render synthesizer and component resolution both skip
// → `<Button/>` usages get no edge and callers/impact return empty
// (the whole shadcn/ui design-system pattern, #841). PascalCase-gated
// to the component naming convention so a memoization util
// (`const cache = memo(fn)`) stays a constant.
if (valueNode && /^[A-Z]/.test(name)) {
const hoc = this.reactComponentHoc(valueNode);
if (hoc) {
this.extractReactComponentNode(name, child, hoc.inner, {
docstring,
signature: initSignature,
isExported,
});
continue;
}
}
const varNode = this.createNode(kind, name, child, {
docstring,
signature: initSignature,