fix(react): a wrapper component renders the component it wraps, and owns the calls in its styles (#2738)

A component declared through a wrapper with no render function of its own
(`styled.div`…``, `styled(ActionButton).attrs(…)<Props>`…``, MUI's
`styled(Box)(({ theme }) => …)`, `memo(Button)`) was a bodyless component
node: nothing walked its initializer, so the calls in its styles went
nowhere, and nothing linked it to the component it wraps, so a render flow
stopped at it. On outline, explore printed no Flow for
"OverflowMenuButton NudeButton ActionButton performAction".

Extraction (TS and the native kernel) now walks the initializer with the
component on the stack: the template's interpolations, the .attrs() /
.withConfig() / options arguments, a style function and a memo comparator
are the component's code. The wrapper's own chain (styled, styled.div,
.attrs, memo) names the library and is not recorded; a typed tag's type
operands are not walked; and the wrapped component is no function-as-value
reference, from the component or from the file (the declaration-wide scan
skips the walked initializer). A component is also a value-ref reader, as
a function is.

The jsx-render synthesizer links a wrapper to the component it wraps
(`wrappedComponentName` in frameworks/react.ts, beside `wrappedName`),
bound as a tag of that name in the file would be, but only where the file
binds the name by a declaration or an import of a project module, so a
package's styled(Link), a Meteor /imports/… path and a destructured name
render nothing rather than a namesake. Files that declare such wrappers are
synthesis inputs, so a sync redraws the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 09:29:05 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7b9c356dd7
commit 2c4a16d091
11 changed files with 669 additions and 35 deletions
+139 -13
View File
@@ -1048,7 +1048,8 @@ export class TreeSitterExtractor {
/**
* Record value-reference bookkeeping as nodes are created: file-scope const/var symbols with
* distinctive names become reference targets; function/method/const/var symbols become reader
* scopes whose bodies flushValueRefs scans.
* scopes whose bodies flushValueRefs scans, and so do React wrapper components, whose
* declaration reads values as a constant's does (`${BaseMenuItemCSS}` in a styled template).
*/
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode, valueTarget = true): void {
// Pascal targets `constant` only: its extractor emits function PARAMETERS
@@ -1079,7 +1080,7 @@ export class TreeSitterExtractor {
this.fileScopeValueCounts.set(name, (this.fileScopeValueCounts.get(name) ?? 0) + 1);
}
}
if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable') {
if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable' || kind === 'component') {
this.valueRefScopes.push({ id, node, name });
}
}
@@ -1584,16 +1585,18 @@ export class TreeSitterExtractor {
const declaredType = this.walkDeclaredTypes ? getChildByField(node, 'type') : null;
const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null;
if (definedType) this.visitNode(definedType);
this.extractVariable(node);
const walked = this.extractVariable(node);
// extractVariable doesn't walk every initializer shape (object literals
// are deliberately skipped; Python/Ruby don't walk at all), so scan the
// declaration subtree for function-as-value candidates — `const routes =
// { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts
// at nested function definitions (their bodies are walked — and
// attributed — separately) and flush-time dedup absorbs any overlap with
// initializers extractVariable DOES walk. A type walked above captured
// its own.
this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : undefined);
// initializers extractVariable DOES walk. A type walked above, and a
// component wrapper's initializer extractVariable walked under the
// component (which must not hand the component it wraps to the file),
// captured their own.
this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : walked);
skipChildren = true; // extractVariable handles children
}
// Swift properties inside a type. A stored instance property becomes a `field`
@@ -2230,7 +2233,8 @@ export class TreeSitterExtractor {
* 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.
* `const Foo = () => …` arrow component already behaves. Without one, the
* initializer is walked instead (walkComponentWrapper).
*/
private extractReactComponentNode(
name: string,
@@ -2239,14 +2243,129 @@ export class TreeSitterExtractor {
extra: { docstring?: string; signature?: string; isExported?: boolean }
): void {
const compNode = this.createNode('component', name, declarator, extra);
if (!compNode || !innerFn || !this.extractor) return;
if (!compNode || !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);
if (innerFn) {
const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
?? getChildByField(innerFn, this.extractor.bodyField);
if (body) this.visitFunctionBody(body, compNode.id);
} else {
const value = getChildByField(declarator, 'value');
if (value) this.walkComponentWrapper(value);
}
this.nodeStack.pop();
}
/**
* Walk a wrapper with no render function of its own — `styled.div\`…\``,
* `styled(Base).attrs(…)<P>\`…\``, emotion's and MUI's `styled(Base)(styles)`,
* `memo(Base)` — with its component on the stack, the way a constant's
* initializer is walked with the constant on it (#693). The template's
* interpolations, the `.attrs()` / `.withConfig()` / options arguments, a
* style function and a `memo` comparator are the component's own code.
*
* The wrapper's own chain is the library it is declared with: `styled`,
* `styled.div`, `.attrs`, `memo` name no project code, so their calls are not
* recorded (every one would park as a reference nothing answers). Nor is the
* component it wraps (`styled(ActionButton)`, `memo(Button)`) a function
* passed as a value: React renders it whenever it renders this one, so the
* JSX synthesizer links it as a render, a hop explore can follow. A type
* argument read as comparisons (`styled.div<Props>`, see
* isTypedStyledTemplate) is a type, and is not walked. Mirrored in the kernel
* (tsjs/extractors.rs walk_component_wrapper).
*/
private walkComponentWrapper(link: SyntaxNode): void {
switch (link.type) {
case 'binary_expression': {
// `(styled(Base) < Props) > \`…\``: the tag is the leftmost operand
// and the template the rightmost; the operands between are the type.
let tag: SyntaxNode | null = link;
while (tag?.type === 'binary_expression') tag = getChildByField(tag, 'left');
let template: SyntaxNode | null = link;
while (template?.type === 'binary_expression') template = getChildByField(template, 'right');
if (tag) this.walkComponentWrapper(tag);
if (template) this.visitFunctionBody(template, '');
return;
}
case 'call_expression': {
const callee = getChildByField(link, 'function');
if (callee) this.walkComponentWrapper(callee);
const args = getChildByField(link, 'arguments');
if (!args) return;
// A tagged template's argument is its template: the styles.
if (args.type !== 'arguments') {
this.visitFunctionBody(args, '');
return;
}
// `styled(Base)`, `memo(Base)`: the first argument is the component
// wrapped, or a wrapper around it (`memo(forwardRef(Inner))`).
const calleeText = callee ? getNodeText(callee, this.source) : '';
const root = calleeText === 'styled' || REACT_COMPONENT_HOCS.has(calleeText);
const first = args.namedChild(0);
const base = root && (first?.type === 'identifier' || first?.type === 'member_expression') ? first : null;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || (base && arg.startIndex === base.startIndex)) continue;
if (root && i === 0 && arg.type === 'call_expression' && this.isComponentWrapperCall(arg)) {
this.walkComponentWrapper(arg);
} else if (arg.type === 'arrow_function' || arg.type === 'function_expression') {
// A style or `.attrs()` function runs as the component's code; its
// body is walked as a render function's is, never named for the
// declarator (curriedWrapperBoundName would take MUI's
// `styled(Box)(({ theme }) => …)` for a function of its own).
const body = this.extractor!.resolveBody?.(arg, this.extractor!.bodyField)
?? getChildByField(arg, this.extractor!.bodyField);
if (body) this.visitFunctionBody(body, '');
} else {
this.visitFunctionBody(arg, '');
}
}
this.captureWrapperArgumentRefs(args, base);
return;
}
case 'member_expression':
case 'subscript_expression': {
const object = getChildByField(link, 'object');
if (object) this.walkComponentWrapper(object);
return;
}
case 'non_null_expression':
case 'parenthesized_expression':
case 'instantiation_expression': {
// `styled.div<{ open: boolean }>` error-recovers into a call on
// `non_null_expression(instantiation_expression(styled.div, …))`.
const inner = getChildByField(link, 'function') ?? link.namedChild(0);
if (inner) this.walkComponentWrapper(inner);
return;
}
}
}
/** A call of the kind reactComponentHoc reads as a component wrapper: `styled…(…)`, `memo(…)`, `forwardRef(…)`. */
private isComponentWrapperCall(call: SyntaxNode): boolean {
const callee = getChildByField(call, 'function');
if (!callee) return false;
const text = getNodeText(callee, this.source);
return /^styled\b/.test(text) || REACT_COMPONENT_HOCS.has(text);
}
/**
* Function-as-value candidates (#756) among a wrapper call's arguments — a
* `memo` comparator, a style function passed by name — attributed to the
* component. The component the call wraps is not one: it is what the
* component renders.
*/
private captureWrapperArgumentRefs(args: SyntaxNode, base: SyntaxNode | null): void {
const spec = this.fnRefSpec;
const rule = spec?.dispatch.get(args.type);
const fromNodeId = this.nodeStack[this.nodeStack.length - 1];
if (!spec || !rule || !fromNodeId) return;
for (const cand of captureFnRefCandidates(args, rule, spec, this.source)) {
if (base && cand.line === base.startPosition.row + 1 && cand.column === base.startPosition.column) continue;
this.fnRefCandidates.push({ ...cand, fromNodeId });
}
}
/**
* Extract a class
*/
@@ -3434,9 +3553,14 @@ export class TreeSitterExtractor {
*
* Extracts top-level and module-level variable declarations.
* Captures the variable name and first 100 chars of initializer in signature for searchability.
*
* Returns the initializers it walked under a component wrapper, whose
* function-as-value candidates that walk already took (the caller's scan of
* the declaration skips them).
*/
private extractVariable(node: SyntaxNode): void {
if (!this.extractor) return;
private extractVariable(node: SyntaxNode): Set<number> | undefined {
if (!this.extractor) return undefined;
let walked: Set<number> | undefined;
// Different languages have different variable declaration structures
// TypeScript/JavaScript: lexical_declaration contains variable_declarator children
@@ -3508,6 +3632,7 @@ export class TreeSitterExtractor {
signature: initSignature,
isExported,
});
if (!hoc.inner) (walked ??= new Set()).add(valueNode.id);
continue;
}
}
@@ -3864,6 +3989,7 @@ export class TreeSitterExtractor {
}
}
}
return walked;
}
/** Resolve a Lua assignment target into its callable name and optional table receiver. */
+68 -8
View File
@@ -40,7 +40,7 @@ import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequest
import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { cppSpecializationArguments, crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
import { declaredComponent, exportedComponent, lazyValueModules } from './frameworks/react';
import { declaredComponent, exportedComponent, lazyValueModules, wrappedComponentName } from './frameworks/react';
import { jsxCode } from './jsx-code';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
@@ -61,6 +61,9 @@ const JSX_FAMILY = ['tsx', 'jsx', 'javascript'];
// the next match, which `opensTag` reads as a type argument, not a tag.
const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g;
const MAX_JSX_CHILDREN = 30;
// A wrapper around a component — `styled(ActionButton)`, `memo(Button)`,
// `forwardRef(Render)` — renders it without a tag (`wrappedComponentName`).
const WRAPPER_CALL_RE = /\b(?:styled|memo|forwardRef)\s*\(\s*[A-Z]/;
// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
// are already caught by JSX_TAG_RE via the SFC component node.
@@ -1889,6 +1892,32 @@ function jsxChild(
return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0];
}
/**
* The component a wrapper component wraps, by the name its declaration hands
* the wrapper (`wrappedComponentName`), bound the way a tag of that name in the
* file is (`jsxChild`), but only where the file binds the name itself: a value
* or a component it declares, or an import of a project module the index
* holds. Anything else names no component of the project, and a namesake
* elsewhere is no guess worth an edge: bigbluebutton's `styled(Icon)`, imported through a
* Meteor `/imports/…` path no resolver follows, would render whichever of its
* dozens of `Icon`s came first, and SigNoz's `styled(Text)` of `const { Text }
* = Typography` a `Text` from another page's styles.
*/
function wrappedChild(
ctx: ResolutionContext,
name: string,
file: string,
importsOf: () => Map<string, string>,
ownValueOf: (name: string) => ReturnType<typeof declaredComponent>,
exportOf: (module: string, exported: string) => Node | null
): Node | undefined {
const declared = ownValueOf(name) !== undefined ||
(ctx.getNodesInFileNamed?.(file, name) ?? ctx.getNodesInFile(file).filter((n) => n.name === name))
.some((n) => JSX_CHILD_KINDS.has(n.kind));
if (!declared && !projectImport(ctx, name, file)) return undefined;
return jsxChild(ctx, name, file, importsOf, ownValueOf, exportOf);
}
/**
* Whether the `<Name` at `at` opens a JSX tag. A type argument list follows
* its type's name directly — `useState<User>()`, `Array<Item>`, the
@@ -2249,12 +2278,47 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
if ((++scannedFiles & 15) === 0) await onYield();
if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs
const content = ctx.readFile(file);
if (!content || (!content.includes('</') && !content.includes('/>'))) continue; // JSX-file gate
if (!content) continue;
const hasTags = content.includes('</') || content.includes('/>');
const wraps = WRAPPER_CALL_RE.test(content);
if (!hasTags && !wraps) continue; // JSX-file gate
// File-level language gate, not merely a project-level one: mixed C/JS
// monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560). A
// TypeScript `.ts` file holds no JSX either: a `<Name>` there is a type
// assertion or text, like outline's OpenSearch XML template.
const inFile = ctx.getNodesInFile(file);
// Once per file and name: the value the file declares under it, if any.
const ownValues = new Map<string, ReturnType<typeof declaredComponent>>();
const ownValueOf = (name: string) => {
if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx));
return ownValues.get(name);
};
// A wrapper component renders the component it wraps, with no tag to read:
// outline's `const NudeButton = styled(ActionButton)…`, a `memo(Button)`.
// Without the link a render flow through it ends at the wrapper. The name
// binds as a tag of that name in this file would, where the file binds it
// (`wrappedChild`), so a package's `styled(Link)` renders nothing here. Any
// JS-family file: a styles `.ts` file declares wrappers too.
if (wraps) {
let wrapperImports: Map<string, string> | null = null;
for (const wrapper of inFile) {
if (wrapper.kind !== 'component' || !JS_FAMILY.includes(wrapper.language)) continue;
const name = wrappedComponentName(wrapper, ctx);
if (!name) continue;
const importsOf = () => (wrapperImports ??= importedFrom(ctx, file, wrapper.language));
const child = wrappedChild(ctx, name, file, importsOf, ownValueOf, exportOf);
if (!child || child.id === wrapper.id || crossesCodeBoundary(wrapper.language, child.language)) continue;
const key = `${wrapper.id}>${child.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: wrapper.id, target: child.id, kind: 'calls', line: wrapper.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'jsx-render', via: name, wrapped: true },
});
}
}
if (!hasTags) continue;
const fns = inFile.filter((n) => PARENT_KINDS.has(n.kind) && JSX_FAMILY.includes(n.language));
// JSX outside every function renders from where a plain call there is
// attributed (#2587): a top-level `const ICONS = { a: <Icon /> }` from the
@@ -2308,12 +2372,6 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let imports: Map<string, string> | null = null;
const importsOf = () =>
(imports ??= importedFrom(ctx, file, parents[0]!.language));
// Once per file and name: the value the file declares under it, if any.
const ownValues = new Map<string, ReturnType<typeof declaredComponent>>();
const ownValueOf = (name: string) => {
if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx));
return ownValues.get(name);
};
// A function renders the tags its own lines hold, so functions that span
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
@@ -4855,6 +4913,8 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean
if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true;
if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true;
if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true;
// A wrapper renders the component it wraps (jsxEdges), in a file with no markup of its own too.
if (WRAPPER_CALL_RE.test(content)) return true;
if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true;
if (/[\w$]\s*\[\s*[A-Za-z_$]/.test(content) || /\b(?:dispatch|commit)\s*\(/.test(content)) return true;
const patterns = [THUNK_DECL_RE, CELERY_TASK_DECORATOR_RE, CELERY_DISPATCH_RE,
+21
View File
@@ -883,6 +883,27 @@ function handedComponent(value: Node, context: ResolutionContext, nodes = contex
: null;
}
/**
* The name of the component a wrapper component's declaration hands its
* rendering to: what `styled(…)` is given (outline's `styled(ActionButton)`,
* whatever `.attrs(…)`, `.withConfig(…)`, type arguments or template follow),
* or what a `memo` or `forwardRef` call wraps (`memo(Button)`, `React.memo(Row,
* areEqual)`, `memo(forwardRef(Input))`; `wrappedName`). React renders it
* whenever it renders the wrapper, so a JSX tag's render flow goes on to it.
* Null for a wrapper of an element (`styled.div`, `styled('a')`) and for one
* around an inline render function, which has a body of its own.
*/
export function wrappedComponentName(component: Node, context: ResolutionContext): string | null {
const code = stripCommentsForRegex(declarationText(component, context), 'typescript');
const init = initializerAt(code, component.name.length);
if (init < 0) return null;
const expression = code.slice(init, statementEnd(code, init)).trim();
const styled = /^styled\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/.exec(expression);
const name = styled ? styled[1]!
: /^(?:React\s*\.\s*)?(?:memo|forwardRef)\b/.test(expression) ? wrappedName(expression) : null;
return name && isPascalCase(name) && name !== component.name ? name : null;
}
/**
* The component a route's `layout:` reference names, found the way the
* route's own component is: through the route file's import of it, else