mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 06:53:52 +08:00
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:
co-authored by
Claude Opus 5.5
parent
7b9c356dd7
commit
2c4a16d091
@@ -9,6 +9,7 @@ use super::{
|
||||
is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker,
|
||||
};
|
||||
use crate::buffers::edge_kind_index;
|
||||
use std::collections::HashSet;
|
||||
use tree_sitter::Node;
|
||||
|
||||
impl<'t> Walker<'t> {
|
||||
@@ -170,14 +171,131 @@ impl<'t> Walker<'t> {
|
||||
let Some(row) = self.create_node("component", name, declarator, extra) else {
|
||||
return;
|
||||
};
|
||||
let Some(inner) = inner_fn else { return };
|
||||
self.stack.push(Scope { row, kind: "component", name: name.to_string() });
|
||||
if let Some(body) = body_of(inner) {
|
||||
self.visit_function_body(body);
|
||||
match inner_fn {
|
||||
Some(inner) => {
|
||||
if let Some(body) = body_of(inner) {
|
||||
self.visit_function_body(body);
|
||||
}
|
||||
}
|
||||
// Without a render function, the initializer is walked instead.
|
||||
None => {
|
||||
if let Some(value) = declarator.child_by_field_name("value") {
|
||||
self.walk_component_wrapper(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.stack.pop();
|
||||
}
|
||||
|
||||
/// A wrapper with no render function of its own (`styled.div\`…\``,
|
||||
/// `styled(Base).attrs(…)<P>\`…\``, `styled(Base)(styles)`, `memo(Base)`),
|
||||
/// walked with its component on the stack as a constant's initializer is
|
||||
/// (#693): the template's interpolations and the `.attrs()` / options /
|
||||
/// style-function / comparator arguments are the component's code. The
|
||||
/// wrapper's own chain (`styled`, `styled.div`, `.attrs`, `memo`) is not
|
||||
/// recorded, the component it wraps is left to the JSX synthesizer (it is
|
||||
/// what this one renders), and a typed tag's type operands are not walked.
|
||||
/// Mirrors TreeSitterExtractor.walkComponentWrapper.
|
||||
fn walk_component_wrapper(&mut self, link: Node<'t>) {
|
||||
stack_guard!();
|
||||
match link.kind() {
|
||||
"binary_expression" => {
|
||||
// `(styled(Base) < Props) > \`…\``: the tag is the leftmost
|
||||
// operand and the template the rightmost; between is the type.
|
||||
let mut tag = Some(link);
|
||||
while let Some(t) = tag.filter(|t| t.kind() == "binary_expression") {
|
||||
tag = t.child_by_field_name("left");
|
||||
}
|
||||
let mut template = Some(link);
|
||||
while let Some(t) = template.filter(|t| t.kind() == "binary_expression") {
|
||||
template = t.child_by_field_name("right");
|
||||
}
|
||||
if let Some(tag) = tag {
|
||||
self.walk_component_wrapper(tag);
|
||||
}
|
||||
if let Some(template) = template {
|
||||
self.visit_function_body(template);
|
||||
}
|
||||
}
|
||||
"call_expression" => {
|
||||
let callee = link.child_by_field_name("function");
|
||||
if let Some(c) = callee {
|
||||
self.walk_component_wrapper(c);
|
||||
}
|
||||
let Some(args) = link.child_by_field_name("arguments") else { return };
|
||||
// A tagged template's argument is its template: the styles.
|
||||
if args.kind() != "arguments" {
|
||||
self.visit_function_body(args);
|
||||
return;
|
||||
}
|
||||
// `styled(Base)`, `memo(Base)`: the first argument is the
|
||||
// component wrapped, or a wrapper around it.
|
||||
let callee_text = callee.map(|c| self.text(c)).unwrap_or("");
|
||||
let root = callee_text == "styled" || is_react_hoc(callee_text);
|
||||
let base = args
|
||||
.named_child(0)
|
||||
.filter(|f| root && matches!(f.kind(), "identifier" | "member_expression"));
|
||||
for i in 0..args.named_child_count() {
|
||||
let Some(arg) = args.named_child(i) else { continue };
|
||||
if base.map(|b| b.start_byte() == arg.start_byte()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if root && i == 0 && arg.kind() == "call_expression" && self.is_component_wrapper_call(arg) {
|
||||
self.walk_component_wrapper(arg);
|
||||
} else if matches!(arg.kind(), "arrow_function" | "function_expression") {
|
||||
// A style or `.attrs()` function runs as the component's
|
||||
// code: its body is walked, the function never named.
|
||||
if let Some(body) = body_of(arg) {
|
||||
self.visit_function_body(body);
|
||||
}
|
||||
} else {
|
||||
self.visit_function_body(arg);
|
||||
}
|
||||
}
|
||||
self.capture_wrapper_argument_refs(args, base);
|
||||
}
|
||||
"member_expression" | "subscript_expression" => {
|
||||
if let Some(object) = link.child_by_field_name("object") {
|
||||
self.walk_component_wrapper(object);
|
||||
}
|
||||
}
|
||||
"non_null_expression" | "parenthesized_expression" | "instantiation_expression" => {
|
||||
// `styled.div<{ open: boolean }>` error-recovers into a call on
|
||||
// `non_null_expression(instantiation_expression(styled.div, …))`.
|
||||
if let Some(inner) = link.child_by_field_name("function").or_else(|| link.named_child(0)) {
|
||||
self.walk_component_wrapper(inner);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A call reactComponentHoc reads as a component wrapper: `styled…(…)`,
|
||||
/// `memo(…)`, `forwardRef(…)`. Mirrors isComponentWrapperCall.
|
||||
fn is_component_wrapper_call(&self, call: Node<'t>) -> bool {
|
||||
let Some(callee) = call.child_by_field_name("function") else { return false };
|
||||
let text = self.text(callee);
|
||||
util::styled_callee().is_match(text) || is_react_hoc(text)
|
||||
}
|
||||
|
||||
/// Function-as-value candidates (#756) among a wrapper call's arguments,
|
||||
/// attributed to the component — never the component the call wraps.
|
||||
/// Mirrors captureWrapperArgumentRefs.
|
||||
fn capture_wrapper_argument_refs(&mut self, args: Node<'t>, base: Option<Node<'t>>) {
|
||||
let Some(mode) = super::fnref::dispatch(args.kind()) else { return };
|
||||
if self.stack.is_empty() {
|
||||
return;
|
||||
}
|
||||
let from = self.top_row();
|
||||
for (cand, _mode) in super::fnref::capture(args, mode, self.src) {
|
||||
if base.map(|b| b.start_byte() == cand.column_byte).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
self.fn_ref_cands.push((from, cand));
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractClass ------------------------------------------------------------
|
||||
|
||||
pub(super) fn extract_class(&mut self, node: Node<'t>) {
|
||||
@@ -412,7 +530,11 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_variable(&mut self, node: Node<'t>) {
|
||||
/// Returns the initializers it walked under a component wrapper, whose
|
||||
/// fn-ref candidates that walk already took (the caller's scan of the
|
||||
/// declaration skips them). Mirrors TreeSitterExtractor.extractVariable.
|
||||
pub(super) fn extract_variable(&mut self, node: Node<'t>) -> Option<HashSet<usize>> {
|
||||
let mut walked: Option<HashSet<usize>> = None;
|
||||
let is_const = self.is_const_decl(node);
|
||||
let kind: &'static str = if is_const { "constant" } else { "variable" };
|
||||
let docstring = crate::docstring::preceding_docstring(node, self.src);
|
||||
@@ -468,6 +590,9 @@ impl<'t> Walker<'t> {
|
||||
..Extra::default()
|
||||
},
|
||||
);
|
||||
if inner.is_none() {
|
||||
walked.get_or_insert_with(HashSet::new).insert(v.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -574,6 +699,7 @@ impl<'t> Walker<'t> {
|
||||
self.extract_object_literal_functions(coll);
|
||||
}
|
||||
}
|
||||
walked
|
||||
}
|
||||
|
||||
/// extractRtkHookBindings — `export const { useGetXQuery } = api`.
|
||||
|
||||
@@ -490,7 +490,9 @@ impl<'t> Walker<'t> {
|
||||
*self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
if matches!(kind, "function" | "method" | "constant" | "variable") {
|
||||
// A React wrapper component reads values as a constant's declaration
|
||||
// does (`${BaseMenuItemCSS}` in a styled template).
|
||||
if matches!(kind, "function" | "method" | "constant" | "variable" | "component") {
|
||||
self.value_scopes.push(ValueScope { row, node, name: name.to_string() });
|
||||
}
|
||||
}
|
||||
@@ -605,11 +607,15 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
|
||||
/// scanFnRefSubtree: capture-only walk of subtrees the main walkers skip.
|
||||
fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
|
||||
/// `walked`: subtrees a body walk has already been through.
|
||||
fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: Option<&HashSet<usize>>) {
|
||||
stack_guard!();
|
||||
if depth > 12 {
|
||||
return;
|
||||
}
|
||||
if walked.map(|w| w.contains(&node.id())).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let kind = node.kind();
|
||||
if depth > 0
|
||||
&& (is_function_type(kind) || matches!(kind, "lambda_literal" | "lambda_expression"))
|
||||
@@ -619,7 +625,7 @@ impl<'t> Walker<'t> {
|
||||
self.maybe_capture_fn_refs(node);
|
||||
for i in 0..node.named_child_count() {
|
||||
if let Some(c) = node.named_child(i) {
|
||||
self.scan_fn_ref_subtree(c, depth + 1);
|
||||
self.scan_fn_ref_subtree(c, depth + 1, walked);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -699,7 +705,7 @@ impl<'t> Walker<'t> {
|
||||
self.visit_function_body(value);
|
||||
self.stack.pop();
|
||||
}
|
||||
self.scan_fn_ref_subtree(node, 0);
|
||||
self.scan_fn_ref_subtree(node, 0, None);
|
||||
} else {
|
||||
self.extract_method(node);
|
||||
}
|
||||
@@ -713,8 +719,10 @@ impl<'t> Walker<'t> {
|
||||
} else if self.variant.is_ts() && kind == "type_alias_declaration" {
|
||||
skip_children = self.extract_type_alias(node);
|
||||
} else if is_variable_type(kind) && !self.inside_class_like() {
|
||||
self.extract_variable(node);
|
||||
self.scan_fn_ref_subtree(node, 0);
|
||||
// A component wrapper's initializer extract_variable walked under
|
||||
// the component captured its own candidates; the scan skips it.
|
||||
let walked = self.extract_variable(node);
|
||||
self.scan_fn_ref_subtree(node, 0, walked.as_ref());
|
||||
skip_children = true;
|
||||
} else if kind == "import_statement" {
|
||||
self.extract_import(node);
|
||||
@@ -750,7 +758,7 @@ impl<'t> Walker<'t> {
|
||||
// says which member wants the type, where `Api → PageId` only said
|
||||
// the file did.
|
||||
self.extract_property(node);
|
||||
self.scan_fn_ref_subtree(node, 0);
|
||||
self.scan_fn_ref_subtree(node, 0, None);
|
||||
skip_children = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user