From 752e445118dafcbab3fff90a3c3d656e6f1c7e07 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 21 Oct 2026 04:26:40 -0500 Subject: [PATCH] fix(react): a wrapper component renders the component it wraps, and owns the calls in its styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component declared through a wrapper with no render function of its own (`styled.div`…``, `styled(ActionButton).attrs(…)`…``, 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 --- CHANGELOG.md | 1 + __tests__/expo-router.test.ts | 6 +- __tests__/fixtures/kernel-parity/torture.js | 10 + __tests__/fixtures/kernel-parity/torture.tsx | 25 ++ __tests__/react-wrapper-initializers.test.ts | 246 +++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 134 +++++++++- codegraph-kernel/src/tsjs/mod.rs | 22 +- docs/design/framework-coverage.md | 11 +- src/extraction/tree-sitter.ts | 152 +++++++++++- src/resolution/callback-synthesizer.ts | 76 +++++- src/resolution/frameworks/react.ts | 21 ++ 11 files changed, 669 insertions(+), 35 deletions(-) create mode 100644 __tests__/react-wrapper-initializers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 365bc1a106..19c04006a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -265,6 +265,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`. - In TypeScript, a type a file uses without importing it, like `Omit`, `Record`, the DOM's `Event`, or a generic's own `T`, no longer links to a type of that name another module of your project declares, like a local `export type Omit` or an enum member `T`: it links to the file's own type or a global one, declared in a script `.d.ts` or a `declare global` block. A type you import, write qualified (`vscode.TextDocument`), bring in with `import X = ns.X`, or name inside a `declare module` block links as before. Re-index TypeScript projects after upgrading. - In TypeScript React files, a type annotation no longer links to a component of the same name. When a file declares a type next to a component or value of the same name, like `type ModalRegistration` beside a `ModalRegistration` component, its annotations now link to the type while its calls still link to the component, and a type the file declares on its own, like `type Option`, or a generic's type parameter, like ``, no longer links to an `Option` or `Item` component elsewhere in the project. Before, `codegraph callers` and impact listed those annotations as uses of the component. Re-index TypeScript React projects after upgrading. +- In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/expo-router.test.ts b/__tests__/expo-router.test.ts index 3463e111d2..5ac1c939c6 100644 --- a/__tests__/expo-router.test.ts +++ b/__tests__/expo-router.test.ts @@ -741,8 +741,10 @@ export default function ReviewScreen() { // One transition into /capture/review, and it comes from /capture. expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']); - // Written right there: no chain, and no Swift guard smuggled in. - expect(links[0]!.via).toEqual([]); + // Written in this screen's own file, reached through the memo wrapper it + // renders, and no Swift guard smuggled in. + expect(links[0]!.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']); + expect(links[0]!.via.every((v) => v.language !== 'swift')).toBe(true); expect(links[0]!.when).toBe('!isRetakeBatchActive'); expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx'); // …and /capture/review is not left feeding only itself. diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 6221c7a775..abe40574f2 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -102,3 +102,13 @@ async function exprReceivers(x) { this.a.b.run(); new Runner().go(); } +// --- component wrappers walked under the component ------------------------------ +const PANEL_GAP = 8; +const Panel = styled(Widget)` + gap: ${PANEL_GAP}px; + color: ${(props) => colorOf(props.theme)}; +`; +const Row = styled.div.attrs((props) => ({ role: roleOf(props) }))` + ${Panel} { margin: 0; } +`; +const MemoPanel = React.memo(Panel, legacyHelper); diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx index 3135422843..ad5fdfaa0b 100644 --- a/__tests__/fixtures/kernel-parity/torture.tsx +++ b/__tests__/fixtures/kernel-parity/torture.tsx @@ -128,6 +128,31 @@ export const Styled = styled.button` `; const memoCache = memo(computeThing); // lowercase — stays a constant +// Wrappers with no render function: the initializer is walked under the +// component — interpolations, `.attrs()` / `.withConfig()` / options +// arguments, a style function, a comparator — but not the wrapper's own chain, +// and the wrapped component is no fn-ref (a typed tag's type is not walked). +const BASE_MIXIN = css`color: red;`; +export const StyledBase = styled(Button)` + color: ${(p) => themeColor(p, 'accent')}; + ${BASE_MIXIN} + ${breakpoint('tablet')`padding: 0;`} +`; +export const AttrsWrapped = styled(ImportedComponent).attrs((props: ButtonProps) => ({ title: labelFor(props) }))` + width: ${(props) => sizeOf(props)}px; +`; +export const Configured = styled(MemoRow) + .withConfig({ shouldForwardProp: (prop) => !isInternalProp(prop) }) + .attrs((props) => ({ align: alignOf(props) }))``; +export const TypedTag = styled.section`margin: ${spacing(2)};`; +export const MuiStyled = styled(ImportedComponent, { shouldForwardProp: helperFn })(({ theme }) => ({ + padding: theme.spacing(1), + color: pickColor(theme, CONFIG_TABLE), +})); +export const MemoCompared = memo(ImportedComponent, helperFn); +export const MemoForward = memo(forwardRef(RenderInput)); +export const RefForwarded = React.forwardRef(RenderInput); + export function App() { const nodes: GraphNode[] = []; return ( diff --git a/__tests__/react-wrapper-initializers.test.ts b/__tests__/react-wrapper-initializers.test.ts new file mode 100644 index 0000000000..614a44d44a --- /dev/null +++ b/__tests__/react-wrapper-initializers.test.ts @@ -0,0 +1,246 @@ +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'; +import { ToolHandler } from '../src/mcp/tools'; +import { resetKernelForTests } from '../src/extraction/kernel'; + +/** + * A React component declared through a wrapper with no render function of its + * own — `styled.div\`…\``, outline's `styled(ActionButton).attrs(…)\`…\``, + * MUI's `styled(Box)(({ theme }) => …)`, `memo(Button)` — is a `component` node + * (#841, #2429) that nothing walked: the calls in its template's + * interpolations went nowhere, where a plain `const X = …` attributes them to X + * (#693), and nothing linked it to the component it wraps, so a render flow + * Page → NudeButton → ActionButton stopped at NudeButton. + * + * Now the initializer is walked under the component, minus the wrapper's own + * chain (`styled`, `styled.div`, `.attrs`, `memo`: the library, which no project + * code answers), and the wrapped component is what the wrapper renders: a + * `jsx-render` hop bound as a tag of that name in the file would be, so a + * package's `styled(Link)` renders no project `Link`. Both engines. + */ + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelBuilt = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; +const ENGINES = [ + { engine: 'wasm', kernel: '0' as string | undefined }, + ...(kernelBuilt ? [{ engine: 'kernel', kernel: undefined as string | undefined }] : []), +]; + +const FILES: Record = { + 'package.json': JSON.stringify({ + dependencies: { react: '^18.0.0', 'styled-components': '^6.0.0', 'react-router-dom': '^6.0.0', antd: '^5.0.0' }, + }), + 'theme.ts': `export function s(key: string) { return key; } +export function breakpoint(size: string) { return (strings: TemplateStringsArray) => size + strings.join(''); } +export function labelFor(props: unknown) { return String(props); } +export function areEqual(a: unknown, b: unknown) { return a === b; } +export function pickColor(theme: unknown) { return String(theme); } +`, + 'ActionButton.tsx': `export default function ActionButton(props: { title?: string }) { + return ; +} +`, + // A project namesake of react-router's Link: what styled(Link) must not render. + 'Link.tsx': `export function Link(props: { to: string }) { + return ; +} +`, + // Namesakes a guess would take for names the wrappers below don't bind to them. + 'other.tsx': `import styled from 'styled-components'; +export const Icon = styled.i\`\`; +export const Text = styled.span\`\`; +`, + // A base imported through a path nothing resolves (a Meteor app's + // `/imports/…`), and one the file destructures from a library. + 'unbound.tsx': `import styled from 'styled-components'; +import Icon from '/imports/ui/components/icon/component'; +import { Typography } from 'antd'; +const { Text } = Typography; +export const BigIcon = styled(Icon)\`\`; +export const StyledText = styled(Text)\`\`; +`, + 'ui.tsx': `import styled, { css } from 'styled-components'; +import { Link } from 'react-router-dom'; +import { memo } from 'react'; +import ActionButton from './ActionButton'; +import { s, breakpoint, labelFor, areEqual, pickColor } from './theme'; + +type Props = { active: boolean }; +const BASE_CSS = css\`color: red;\`; + +export const Plain = styled.div\` + color: \${(p) => s('accent')}; + \${BASE_CSS} + \${breakpoint('tablet')\`padding: 0;\`} +\`; +export const NudeButton = styled(ActionButton).attrs((props: Props) => ({ title: labelFor(props) }))\` + width: \${(props) => s('width')}; +\`; +export const Root = styled(ActionButton)(({ theme }) => ({ color: pickColor(theme) })); +export const Memoized = memo(ActionButton, areEqual); +const Box = styled.div\`\`; +export const Card = styled(Box)\`\`; +export const StyledLink = styled(Link)\`\`; +`, + // A styles file: TypeScript, no markup of its own. + 'styles.ts': `import styled from 'styled-components'; +import ActionButton from './ActionButton'; +export const Panel = styled(ActionButton)\`margin: 0;\`; +`, + // A wrapper of an element only, in a file no synthesis pass reads yet. + 'bare.ts': `import styled from 'styled-components'; +import ActionButton from './ActionButton'; +export const Bare = styled.div\`margin: 0;\`; +`, + 'Page.tsx': `import { NudeButton } from './ui'; +export function Page() { + return ; +} +`, +}; + +describe.each(ENGINES)('a wrapper component walks its initializer and renders what it wraps ($engine)', ({ kernel }) => { + let dir: string; + let cg: any; + let saved: string | undefined; + + beforeEach(() => { + saved = process.env.CODEGRAPH_KERNEL; + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + resetKernelForTests(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-wrap-')); + for (const [file, text] of Object.entries(FILES)) fs.writeFileSync(path.join(dir, file), text); + }); + + afterEach(() => { + cg?.close?.(); + cg = undefined; + if (saved === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = saved; + resetKernelForTests(); + 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; + } + + /** `kind` edges from a component to a node, as `Source>target`. */ + const edgesFrom = (db: any, kind: string, where = '1=1'): string[] => + db + .prepare( + `SELECT s.name src, t.name dst FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = ? AND s.kind = 'component' AND ${where} + ORDER BY s.name, t.name` + ) + .all(kind) + .map((r: any) => `${r.src}>${r.dst}`); + + it('attributes the calls and values in its template, attrs and style function to the component', async () => { + const db = await index(); + const calls = new Set(edgesFrom(db, 'calls', "json_extract(e.metadata, '$.synthesizedBy') IS NULL")); + for (const edge of ['Plain>s', 'Plain>breakpoint', 'NudeButton>labelFor', 'NudeButton>s', 'Root>pickColor']) { + expect(calls, edge).toContain(edge); + } + // The style function MUI hands the wrapper is the component's code, not a + // function of its own named for the declarator. + expect(db.prepare("SELECT kind FROM nodes WHERE name = 'Root'").all().map((r: any) => r.kind)).toEqual(['component']); + // A file-scope value the template reads, and a memo comparator passed by name. + expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.valueRef') = 1")).toContain('Plain>BASE_CSS'); + expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.fnRef') = 1")).toEqual(['Memoized>areEqual']); + }); + + it('records none of the wrapper machinery, and no function-as-value reference to the wrapped component', async () => { + const db = await index(); + const fromComponents = db + .prepare( + `SELECT r.reference_name name FROM unresolved_refs r JOIN nodes n ON n.id = r.from_node_id + WHERE n.kind = 'component'` + ) + .all() + .map((r: any) => r.name as string); + for (const machinery of ['styled', 'styled.div', 'styled().attrs', 'memo', 'React.memo']) { + expect(fromComponents, machinery).not.toContain(machinery); + } + expect(fromComponents.filter((n) => n.startsWith('styled('))).toEqual([]); + // The wrapped component is rendered, not passed as a value: no fn-ref from the + // component or from the file, resolved or not. + const fnRefs = db + .prepare( + `SELECT r.reference_name name FROM unresolved_refs r WHERE r.reference_kind = 'function_ref' + UNION ALL + SELECT t.name FROM edges e JOIN nodes t ON t.id = e.target WHERE json_extract(e.metadata, '$.fnRef') = 1` + ) + .all() + .map((r: any) => r.name as string); + for (const wrapped of ['ActionButton', 'Box', 'Link']) expect(fnRefs, wrapped).not.toContain(wrapped); + }); + + it('renders the component it wraps, bound as a tag of that name in its file', async () => { + const db = await index(); + const rows = db + .prepare( + `SELECT s.name src, t.name dst, t.file_path file, e.metadata meta FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.wrapped') = 1 ORDER BY s.name, t.name` + ) + .all(); + expect(rows.map((r: any) => `${r.src}>${r.dst}@${r.file}`)).toEqual([ + 'Card>Box@ui.tsx', + 'Memoized>ActionButton@ActionButton.tsx', + 'NudeButton>ActionButton@ActionButton.tsx', + 'Panel>ActionButton@ActionButton.tsx', + 'Root>ActionButton@ActionButton.tsx', + ]); + for (const r of rows) { + expect(JSON.parse(r.meta)).toMatchObject({ synthesizedBy: 'jsx-render', via: r.dst, wrapped: true }); + } + // react-router's Link renders no project namesake, nor does a name the file + // imports from a module the index can't follow, or destructures itself. + for (const wrapper of ['StyledLink', 'BigIcon', 'StyledText']) { + expect(edgesFrom(db, 'calls').filter((e) => e.startsWith(`${wrapper}>`)), wrapper).toEqual([]); + } + }); + + it('lets explore follow a render flow through the wrapper to the component it wraps', async () => { + await index(); + const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'Page NudeButton ActionButton' }); + const text: string = res.content?.[0]?.text ?? ''; + const flow = text.slice(text.indexOf('**Flow')); + expect(text).toContain('**Flow'); + const steps = [...flow.matchAll(/^(\d+)\. (\w+) \(/gm)].map((m) => `${m[1]}. ${m[2]}`); + expect(steps.slice(0, 3)).toEqual(['1. Page', '2. NudeButton', '3. ActionButton']); + }); + + it('draws the render link when a sync turns a wrapper in a file with no markup onto a component', async () => { + const db = await index(); + const wrapped = () => + db + .prepare( + `SELECT s.name src, t.name dst FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.wrapped') = 1 AND s.name = 'Bare'` + ) + .all() + .map((r: any) => `${r.src}>${r.dst}`); + expect(wrapped()).toEqual([]); + // No markup, no synthesized edge and the same component names before and + // after: only the wrapper itself says the JSX edges need redrawing. + fs.writeFileSync(path.join(dir, 'bare.ts'), FILES['bare.ts']!.replace('styled.div`', 'styled(ActionButton)`')); + await cg.sync(); + expect(wrapped()).toEqual(['Bare>ActionButton']); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 2effd5ba3b..fb8650b555 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -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(…)

\`…\``, `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>) { + 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> { + let mut walked: Option> = 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`. diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 2471dc0bb9..4fd30ced06 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -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>) { 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; } diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 5739b73d7b..e94571780e 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -411,7 +411,16 @@ Each of these cost real debugging time; they are not hypothetical. hands over `Login` (`defaultExportedName`). A tag's edge now depends on the modules the file's lazy values load, so those are synthesis inputs (`lazyValueModules`): a sync that gives one its default export redraws the - tag, as a fresh index would draw it. + tag, as a fresh index would draw it. A component node minted for a wrapper + with no render function of its own (outline's `styled(ActionButton).attrs(…) + \`…\``, `memo(Button)`) renders the component it wraps, with no tag to + read: `wrappedComponentName` reads the name off the declaration, and the + `jsx-render` synthesizer binds it as `jsxChild` binds a tag, but only where + the file binds the name itself, by a declaration or an import of a project + module (`wrappedChild`). bigbluebutton imports through Meteor `/imports/…` + paths no resolver follows, and a guess among its dozens of styled `Icon`s + and `FlexColumn`s was wrong every time. A file that declares such a wrapper + is a synthesis input even with no markup. 20. **An import that looks like a package can be a path.** Create React App and Vite admin templates (berry, mantis, matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f6657357a8..e0ae68852d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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(…)

\`…\``, 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`, 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 | undefined { + if (!this.extractor) return undefined; + let walked: Set | 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. */ diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 1fbd4327ff..86db4faf18 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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 ( → ElButton) and // event bindings (@click="fn" / v-on:click="fn"). PascalCase children () // 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, + ownValueOf: (name: string) => ReturnType, + 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 `()`, `Array`, 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(''))) continue; // JSX-file gate + if (!content) continue; + const hasTags = 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 `""` inside C as JSX (#1560). A // TypeScript `.ts` file holds no JSX either: a `` 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>(); + 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 | 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: }` from the @@ -2308,12 +2372,6 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): let imports: Map | 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>(); - 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, diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 0a61b2651f..93eccf257f 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -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