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
+1
View File
@@ -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 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<T, K>`, `Record<K, V>`, 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, a type a file uses without importing it, like `Omit<T, K>`, `Record<K, V>`, 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 `<Item>`, 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 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 `<Item>`, 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 ## [1.6.2] - 2026-10-03
+4 -2
View File
@@ -741,8 +741,10 @@ export default function ReviewScreen() {
// One transition into /capture/review, and it comes from /capture. // 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']); 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. // Written in this screen's own file, reached through the memo wrapper it
expect(links[0]!.via).toEqual([]); // 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]!.when).toBe('!isRetakeBatchActive');
expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx'); expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
// …and /capture/review is not left feeding only itself. // …and /capture/review is not left feeding only itself.
@@ -102,3 +102,13 @@ async function exprReceivers(x) {
this.a.b.run(); this.a.b.run();
new Runner().go(); 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);
@@ -128,6 +128,31 @@ export const Styled = styled.button`
`; `;
const memoCache = memo(computeThing); // lowercase — stays a constant 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) }))<ButtonProps>`
width: ${(props) => sizeOf(props)}px;
`;
export const Configured = styled(MemoRow)
.withConfig({ shouldForwardProp: (prop) => !isInternalProp(prop) })
.attrs<RowProps>((props) => ({ align: alignOf(props) }))<RowProps>``;
export const TypedTag = styled.section<ButtonProps | RowProps>`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() { export function App() {
const nodes: GraphNode[] = []; const nodes: GraphNode[] = [];
return ( return (
@@ -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(…)<Props>\`…\``,
* 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<string, string> = {
'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 <button>{props.title}</button>;
}
`,
// A project namesake of react-router's Link: what styled(Link) must not render.
'Link.tsx': `export function Link(props: { to: string }) {
return <a href={props.to} />;
}
`,
// 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) }))<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 <NudeButton active />;
}
`,
};
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']);
});
});
+130 -4
View File
@@ -9,6 +9,7 @@ use super::{
is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker, is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker,
}; };
use crate::buffers::edge_kind_index; use crate::buffers::edge_kind_index;
use std::collections::HashSet;
use tree_sitter::Node; use tree_sitter::Node;
impl<'t> Walker<'t> { impl<'t> Walker<'t> {
@@ -170,14 +171,131 @@ impl<'t> Walker<'t> {
let Some(row) = self.create_node("component", name, declarator, extra) else { let Some(row) = self.create_node("component", name, declarator, extra) else {
return; return;
}; };
let Some(inner) = inner_fn else { return };
self.stack.push(Scope { row, kind: "component", name: name.to_string() }); self.stack.push(Scope { row, kind: "component", name: name.to_string() });
if let Some(body) = body_of(inner) { match inner_fn {
self.visit_function_body(body); 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(); 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 ------------------------------------------------------------ // --- extractClass ------------------------------------------------------------
pub(super) fn extract_class(&mut self, node: Node<'t>) { 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 is_const = self.is_const_decl(node);
let kind: &'static str = if is_const { "constant" } else { "variable" }; let kind: &'static str = if is_const { "constant" } else { "variable" };
let docstring = crate::docstring::preceding_docstring(node, self.src); let docstring = crate::docstring::preceding_docstring(node, self.src);
@@ -468,6 +590,9 @@ impl<'t> Walker<'t> {
..Extra::default() ..Extra::default()
}, },
); );
if inner.is_none() {
walked.get_or_insert_with(HashSet::new).insert(v.id());
}
continue; continue;
} }
} }
@@ -574,6 +699,7 @@ impl<'t> Walker<'t> {
self.extract_object_literal_functions(coll); self.extract_object_literal_functions(coll);
} }
} }
walked
} }
/// extractRtkHookBindings — `export const { useGetXQuery } = api`. /// extractRtkHookBindings — `export const { useGetXQuery } = api`.
+15 -7
View File
@@ -490,7 +490,9 @@ impl<'t> Walker<'t> {
*self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1; *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() }); 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. /// 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!(); stack_guard!();
if depth > 12 { if depth > 12 {
return; return;
} }
if walked.map(|w| w.contains(&node.id())).unwrap_or(false) {
return;
}
let kind = node.kind(); let kind = node.kind();
if depth > 0 if depth > 0
&& (is_function_type(kind) || matches!(kind, "lambda_literal" | "lambda_expression")) && (is_function_type(kind) || matches!(kind, "lambda_literal" | "lambda_expression"))
@@ -619,7 +625,7 @@ impl<'t> Walker<'t> {
self.maybe_capture_fn_refs(node); self.maybe_capture_fn_refs(node);
for i in 0..node.named_child_count() { for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) { 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.visit_function_body(value);
self.stack.pop(); self.stack.pop();
} }
self.scan_fn_ref_subtree(node, 0); self.scan_fn_ref_subtree(node, 0, None);
} else { } else {
self.extract_method(node); self.extract_method(node);
} }
@@ -713,8 +719,10 @@ impl<'t> Walker<'t> {
} else if self.variant.is_ts() && kind == "type_alias_declaration" { } else if self.variant.is_ts() && kind == "type_alias_declaration" {
skip_children = self.extract_type_alias(node); skip_children = self.extract_type_alias(node);
} else if is_variable_type(kind) && !self.inside_class_like() { } else if is_variable_type(kind) && !self.inside_class_like() {
self.extract_variable(node); // A component wrapper's initializer extract_variable walked under
self.scan_fn_ref_subtree(node, 0); // 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; skip_children = true;
} else if kind == "import_statement" { } else if kind == "import_statement" {
self.extract_import(node); self.extract_import(node);
@@ -750,7 +758,7 @@ impl<'t> Walker<'t> {
// says which member wants the type, where `Api → PageId` only said // says which member wants the type, where `Api → PageId` only said
// the file did. // the file did.
self.extract_property(node); self.extract_property(node);
self.scan_fn_ref_subtree(node, 0); self.scan_fn_ref_subtree(node, 0, None);
skip_children = true; skip_children = true;
} }
+10 -1
View File
@@ -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 hands over `Login` (`defaultExportedName`). A tag's edge now depends on the
modules the file's lazy values load, so those are synthesis inputs modules the file's lazy values load, so those are synthesis inputs
(`lazyValueModules`): a sync that gives one its default export redraws the (`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(…)
<Props>\`…\``, `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 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 and Vite admin templates (berry, mantis, matx) set `"baseUrl": "src"` or
`"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file `"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file
+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 * 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 * 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 { private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode, valueTarget = true): void {
// Pascal targets `constant` only: its extractor emits function PARAMETERS // 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); 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 }); this.valueRefScopes.push({ id, node, name });
} }
} }
@@ -1584,16 +1585,18 @@ export class TreeSitterExtractor {
const declaredType = this.walkDeclaredTypes ? getChildByField(node, 'type') : null; const declaredType = this.walkDeclaredTypes ? getChildByField(node, 'type') : null;
const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null; const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null;
if (definedType) this.visitNode(definedType); if (definedType) this.visitNode(definedType);
this.extractVariable(node); const walked = this.extractVariable(node);
// extractVariable doesn't walk every initializer shape (object literals // extractVariable doesn't walk every initializer shape (object literals
// are deliberately skipped; Python/Ruby don't walk at all), so scan the // are deliberately skipped; Python/Ruby don't walk at all), so scan the
// declaration subtree for function-as-value candidates — `const routes = // declaration subtree for function-as-value candidates — `const routes =
// { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts // { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts
// at nested function definitions (their bodies are walked — and // at nested function definitions (their bodies are walked — and
// attributed — separately) and flush-time dedup absorbs any overlap with // attributed — separately) and flush-time dedup absorbs any overlap with
// initializers extractVariable DOES walk. A type walked above captured // initializers extractVariable DOES walk. A type walked above, and a
// its own. // component wrapper's initializer extractVariable walked under the
this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : undefined); // 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 skipChildren = true; // extractVariable handles children
} }
// Swift properties inside a type. A stored instance property becomes a `field` // 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 * 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 * function, its body is walked so the component's callees (hooks, helpers) are
* captured under the component node — matching how a plain * 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( private extractReactComponentNode(
name: string, name: string,
@@ -2239,14 +2243,129 @@ export class TreeSitterExtractor {
extra: { docstring?: string; signature?: string; isExported?: boolean } extra: { docstring?: string; signature?: string; isExported?: boolean }
): void { ): void {
const compNode = this.createNode('component', name, declarator, extra); const compNode = this.createNode('component', name, declarator, extra);
if (!compNode || !innerFn || !this.extractor) return; if (!compNode || !this.extractor) return;
this.nodeStack.push(compNode.id); this.nodeStack.push(compNode.id);
const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField) if (innerFn) {
?? getChildByField(innerFn, this.extractor.bodyField); const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
if (body) this.visitFunctionBody(body, compNode.id); ?? 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(); 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 * Extract a class
*/ */
@@ -3434,9 +3553,14 @@ export class TreeSitterExtractor {
* *
* Extracts top-level and module-level variable declarations. * Extracts top-level and module-level variable declarations.
* Captures the variable name and first 100 chars of initializer in signature for searchability. * 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 { private extractVariable(node: SyntaxNode): Set<number> | undefined {
if (!this.extractor) return; if (!this.extractor) return undefined;
let walked: Set<number> | undefined;
// Different languages have different variable declaration structures // Different languages have different variable declaration structures
// TypeScript/JavaScript: lexical_declaration contains variable_declarator children // TypeScript/JavaScript: lexical_declaration contains variable_declarator children
@@ -3508,6 +3632,7 @@ export class TreeSitterExtractor {
signature: initSignature, signature: initSignature,
isExported, isExported,
}); });
if (!hoc.inner) (walked ??= new Set()).add(valueNode.id);
continue; continue;
} }
} }
@@ -3864,6 +3989,7 @@ export class TreeSitterExtractor {
} }
} }
} }
return walked;
} }
/** Resolve a Lua assignment target into its callable name and optional table receiver. */ /** 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 { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver'; import { resolveImportPath } from './import-resolver';
import { cppSpecializationArguments, crossesCodeBoundary, jsCodeBindsName } from './name-matcher'; 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'; import { jsxCode } from './jsx-code';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; 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. // 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 JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g;
const MAX_JSX_CHILDREN = 30; 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 // Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>) // event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
// are already caught by JSX_TAG_RE via the SFC component node. // 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]; 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 * Whether the `<Name` at `at` opens a JSX tag. A type argument list follows
* its type's name directly — `useState<User>()`, `Array<Item>`, the * 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 ((++scannedFiles & 15) === 0) await onYield();
if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs
const content = ctx.readFile(file); 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 // File-level language gate, not merely a project-level one: mixed C/JS
// monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560). A // monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560). A
// TypeScript `.ts` file holds no JSX either: a `<Name>` there is a type // TypeScript `.ts` file holds no JSX either: a `<Name>` there is a type
// assertion or text, like outline's OpenSearch XML template. // assertion or text, like outline's OpenSearch XML template.
const inFile = ctx.getNodesInFile(file); 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)); 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 // JSX outside every function renders from where a plain call there is
// attributed (#2587): a top-level `const ICONS = { a: <Icon /> }` from the // 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; let imports: Map<string, string> | null = null;
const importsOf = () => const importsOf = () =>
(imports ??= importedFrom(ctx, file, parents[0]!.language)); (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 // 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 // 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 // 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 (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true;
if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, 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; 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 (/\.(?: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; 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, 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; : 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 * 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 * route's own component is: through the route file's import of it, else