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
+17 -65
View File
@@ -9,7 +9,12 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from
export const reactResolver: FrameworkResolver = {
name: 'react',
languages: ['javascript', 'typescript'],
// Includes 'tsx'/'jsx' so route extraction runs on JSX files (where
// `<Route element={<X/>}>` routes live) — without them the .tsx/.jsx grammars
// were filtered out of the extract pass and those routes were never indexed.
// (resolve() is unaffected — it runs for every detected framework regardless
// of language; only the extract pass filters on `languages`.)
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
detect(context: ResolutionContext): boolean {
// Check for React in package.json
@@ -90,70 +95,17 @@ export const reactResolver: FrameworkResolver = {
const references: UnresolvedRef[] = [];
const now = Date.now();
// Extract component definitions
// function Component() or const Component = () =>
const componentPatterns = [
// Function components
/(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g,
// Arrow function components
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z_][a-zA-Z0-9_]*)\s*=>/g,
// forwardRef components
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?forwardRef/g,
// memo components
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?memo/g,
];
for (const pattern of componentPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const [fullMatch, name] = match;
const line = content.slice(0, match.index).split('\n').length;
// Check if it returns JSX (rough heuristic)
const afterMatch = content.slice(match.index + fullMatch.length, match.index + fullMatch.length + 500);
const hasJSX = afterMatch.includes('<') && (afterMatch.includes('/>') || afterMatch.includes('</'));
if (hasJSX) {
nodes.push({
id: `component:${filePath}:${name}:${line}`,
kind: 'component',
name: name!,
qualifiedName: `${filePath}::${name}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: fullMatch.length,
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
isExported: fullMatch.includes('export'),
updatedAt: now,
});
}
}
}
// Extract custom hooks
const hookPattern = /(?:export\s+)?(?:function|const|let)\s+(use[A-Z][a-zA-Z0-9]*)\s*[=(]/g;
let hookMatch;
while ((hookMatch = hookPattern.exec(content)) !== null) {
const [fullMatch, name] = hookMatch;
const line = content.slice(0, hookMatch.index).split('\n').length;
nodes.push({
id: `hook:${filePath}:${name}:${line}`,
kind: 'function',
name: name!,
qualifiedName: `${filePath}::${name}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: fullMatch.length,
language: filePath.endsWith('.ts') || filePath.endsWith('.tsx') ? 'typescript' : 'javascript',
isExported: fullMatch.includes('export'),
updatedAt: now,
});
}
// Components and custom hooks are NOT extracted here. The tree-sitter
// extractor already emits them natively across .ts/.tsx/.js/.jsx — function
// and arrow components as `function` nodes, HOC-wrapped components
// (`forwardRef`/`memo`/`styled`) as `component` nodes (#841), and `useX`
// hooks as `function` nodes. Re-deriving them here with regex only ran on
// .ts/.js anyway (this resolver's `languages` didn't include the 'tsx'/'jsx'
// grammars), and it DUPLICATED those tree-sitter nodes (e.g. a `useAuth`
// ended up as two `function` nodes). This `extract` now contributes only
// what tree-sitter can't: route nodes (React Router + Next.js conventions),
// which is why 'tsx'/'jsx' are now in `languages` — `<Route>`/`element={<X/>}`
// routes live in JSX files and were previously skipped entirely.
// React Router: <Route path="/x" component={Comp}/> (v5) or
// <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,