Skip to content

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

Merged
colbymchenry merged 1 commit into
mainfrom
claude/blissful-lamport-119adb
Oct 21, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/blissful-lamport-119adb

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

A React component declared through a wrapper with no render function of its own — styled.div…, outline's `styled(ActionButton).attrs(…)<Props>`…, emotion's and MUI's styled(Box)(({ theme }) => …), memo(Button) — is a bodyless component node (#841, #2429). Nothing walked its initializer, so:

  • the calls and values in its styles went nowhere, where a plain const X = … attributes them to X (CodeGraph does not index call edges from anonymous/lambda functions #693);
  • nothing linked it to the component it wraps, so a render flow stopped at the wrapper. On outline, codegraph_explore "OverflowMenuButton NudeButton ActionButton performAction" printed no Flow at all. Now:
1. OverflowMenuButton (app/components/Menu/OverflowMenuButton.tsx:15)
   ↓ dynamic: renders <NudeButton>
2. NudeButton (app/components/NudeButton.tsx:13)
   ↓ dynamic: renders <ActionButton>
3. ActionButton (app/components/ActionButton.tsx:24)
   ↓ calls (when … && actionContext)
4. performAction (app/actions/index.ts:328)

and DocumentCard PinButton NudeButton ActionButton connects through two wrappers (PinButton = styled(NudeButton)).

What changed

Extraction (both engines, byte-identical). extractReactComponentNode walks a bodyless wrapper's initializer with the component on the stack (walkComponentWrapper / walk_component_wrapper):

  • The payload is the component's code: the template's interpolations, .attrs(…) / .withConfig(…) / options arguments, the style function MUI and emotion take (its body is walked directly, so curriedWrapperBoundName doesn't mint a function named for the declarator), a memo comparator (memo(X, areEqual) → fn-ref to areEqual).
  • The wrapper's own chain is not recorded: styled, styled.div, styled().attrs, memo, React.memo, forwardRef and the garbage callee text styled(Base) name the library, which no project code answers; each would park as a failed ref per wrapper.
  • The wrapped component is not a function-as-value reference (from the component, nor from the file: the declaration-wide fn-ref scan now skips the initializer the walk covered). It is what the wrapper renders — see below.
  • A typed tag's type operands are not walked: (styled(Base) < Props) > …`` walks the leftmost operand (the tag) and the rightmost (the template), nothing between (fix(react): a typed styled component is a component, not a constant #2429's shape).
  • A component is a value-ref reader, so a styled template's ${BaseMenuItemCSS} is a references edge, and so is a forwardRef((props, ref) => …) component's read of a top-level constant, as a function component's is. Components were left out only because forwardRef components get no JSX render edges — callers/impact silently return empty #841 came a week after value refs.

Resolution. The jsx-render synthesizer links a wrapper component to the component it wraps, with { synthesizedBy: 'jsx-render', via, wrapped: true }, so explore's Flow, the codegraph_node trail, context call paths and the viewer's Steps all show it as renders <ActionButton>.

  • The name comes from wrappedComponentName in frameworks/react.ts: styled(X…, else main's wrappedName (from fix(react): a component a file declares itself as a value is what its JSX renders #2420) for memo / forwardRef shapes — nested memo(forwardRef(X)), React.memo(X, areEqual), forwardRef(X) as …. No third reader.
  • It binds as a tag of that name in the file would (jsxChild: the file's own value or component, a lazy value's module, an import followed through barrels and held bindings, a package import → nothing), but only where the file binds the name itself (wrappedChild): by a declaration, or an import of a project module the index holds. bigbluebutton imports through Meteor /imports/… paths no resolver follows, and the name fallback chose among its dozens of styled Icon / FlexColumn / Header components. Measured against the files those paths name, about one hop in five was wrong (34 of 177, every one bound to a component in another file than the import's). SigNoz's styled(Text) of const { Text } = Typography bound another page's styled Text.
  • A file that declares such a wrapper is a synthesis input (hasSynthesisPattern), so a sync that turns styled.div into styled(ActionButton) in a file with no markup redraws the edge. Before, nothing in such a file asked for a refresh.

Decisions asked for

  • Render hop, not a references fn-ref. Explore's Flow follows only calls and navigates, and function_ref resolves only to functions and methods, so it missed every styled base (styled(Flex): outline's Flex is itself a styled component). On main the fn-ref also came from the file, not the wrapper: outline's Disclosure.tsx default-exports StyledDisclosure = styled(Disclosure), and the two files that wrap that import had their fn-ref bound to the inner Disclosure function. They now render StyledDisclosure, which renders Disclosure.
  • Noise. The machinery refs are gone. Payload calls to packages are recorded, as in any function body: css (outline 56, bigbluebutton 45), polished's transparentize/darken, styled-components-breakpoint's breakpoint (55), MUI's theme.spacing and theme.breakpoints.down. Net, the unresolved table shrinks in 5 of the 10 changed repos: the file-level fn-refs to bases that never resolved go away (outline −257, bigbluebutton −164, SigNoz −79).

A/B

Both arms on main 7b9c356, each with its own kernel. Nodes are identical in every repo.

repo wrapper hops calls from components value refs fn-refs removed (file-level fn-refs)
outline +354 +553 +45 +1 −95 (93 now hops, 2 corrected)
bigbluebutton-html5 +37 +3 +32 +1 −7 (1 hop, 1 fn-ref, 5 dropped)
berry +6 +5 +1 −2 (both hops)
matx +6 +3 −5 (all hops)
excalidraw +3 +28 +3 −6 (3 hops, 3 fn-refs)
SigNoz +2 +5 +7 +1 −2 (1 hop, 1 fn-ref)
mastodon +2 +7 −2 (both hops)
minimal-kit +1 −1 (hop)
mantis +16
bulletproof-react +18
next-saas-starter, proshop, create-t3-turbo byte-identical

Checked:

  • Every hop against the source. The declaration wraps via, and the target is the file's own declaration or lazy value, or the module the import names.
    • outline: all 354 — 192 at the imported file, 143 through outline's forwarding modules (app/components/Flex.tsx is import Flex from "@shared/components/Flex"; export default Flex, likewise Text.ts and Avatar/index.ts), 19 own declarations or lazy values.
    • The other repos' 57 likewise.
  • Removed edges. Every one is a file-level fn-ref to a wrapper's base. bigbluebutton's 5 dropped ones are Meteor /imports/… bases (right target, attributed to the file), which the binding rule now leaves unlinked.
  • Added calls. The 23 exact-match calls are same-file style helpers (openedMixin(theme), getColorStyle(…), pulse(color), endPadding(16)); the import-resolved ones are theme helpers (s, breakpoint, ellipsis, undraggableOnDesktop…).
  • Value refs and fn-refs. Spot-checked: excalidraw's icon tables and constants, shadcn's buttonVariants / FormItemContext, the memo comparators.

Cost: the jsxEdges pass, median of 3 interleaved full indexes, goes 1537 → 1572 ms on outline and 624 → 664 ms on bigbluebutton. Whole-index wall time is unchanged within noise (outline 15.3 → 15.1 s).

Kernel parity: scripts/kernel-parity.mjs over outline, bigbluebutton, SigNoz, excalidraw, matx, berry, mantis, minimal-kit and mastodon: 0 files with diffs (about 11,000 files), with the same deferral counts as main.

Tests

  • __tests__/react-wrapper-initializers.test.ts, under both engines: interpolation, .attrs and style-function calls; no machinery refs and no fn-ref to a wrapped base; the hops, including a .ts styles file, a package Link, an unresolvable import and a destructured name; explore's Flow Page → NudeButton → ActionButton; a sync of a markup-free file. All 10 fail on main. The binding gate was checked by mutation: disabling it brings back exactly the two guessed hops.
  • Kernel parity: the wrapper shapes were added to fixtures/kernel-parity/torture.tsx and torture.js (typed tags, .withConfig, MUI's curried style function, memo(X, cmp), memo(forwardRef(X))).
  • Full suite (--maxWorkers=4, kernel built, CODEGRAPH_KERNEL_EXPECT=1): 688 files, 687 passed; 8,243 tests, 8,160 passed, 82 skipped, 1 failed. The failure was expo-router.test.ts's native-bridge case: its screen renders MemoizedCaptureComponent = memo(CaptureComponent), and the push lives in CaptureComponent. The viewer's Screens link from /capture used to stop at the wrapper and file the push by file (via: []); it now reaches the push through the wrapper (via: [MemoizedCaptureComponent, CaptureComponent]), and still has no Swift node, the same guard and the same site. The expectation is updated in this PR, and the file reruns green.

Overlap

Follow-ups (separate PRs)

  • Meteor's root-absolute /imports/… specifiers resolve nowhere, so bigbluebutton's imports through them link nothing, and its tags, calls and these hops fall back to name guesses or nothing. A lone-candidate guess was right 40 of 42 times there, but default imports under another name broke it; resolving the specifier is the fix.
  • extractCall stores a callee's whole source text as a ref name when the callee is itself a call, a parenthesized expression or an arrow: IIFEs, connect(m)(X), breakpoint("tablet")…``. That is 120-235 rows per repo on main, some kilobytes long.
  • A constant's initializer fn-refs are captured twice, from the constant and again from the file, because the declaration-wide scan doesn't skip what the walk covered. On outline that makes 75 duplicate file-level edges.

🤖 Generated with Claude Code

…wns the calls in its styles

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>
@colbymchenry
colbymchenry merged commit 2c4a16d into main Oct 21, 2026
@colbymchenry
colbymchenry deleted the claude/blissful-lamport-119adb branch October 21, 2026 09:29
colbymchenry added a commit that referenced this pull request Oct 21, 2026
…what the initializer declares (#2743)

PRs #693 and #1802 walk a declaration's initializer under the symbol it declares,
so `const routes = { home }` references `home` from `routes`. The
dispatcher's declaration-wide function-as-value scan then took the same
values again for the enclosing file (or class). Flush-time dedup keys on
the source, so both stayed: a second reference to the same function from
the same spot.

Each initializer walk now records what it walked, and the scan skips it.
PR #2738 added that mechanism for component wrappers, and C#, Scala and Dart
already had their own. The newly covered paths:
- TS/JS module-level constants and variables, owned object literals
  included (#2300), and TS/JS class properties;
- Python assignments, Go var/const specs, Rust const/static items;
- Java fields, and Kotlin properties (walkInitializer takes the property's
  id, so their walk is unchanged).
Mirrored in the kernel: tsjs, python, go, rustlang, java and kotlin.

References no initializer walk takes are unchanged: a module-level call
argument `register(fn)`, C-family tables, Rust's `static CB: fn() = handler`
(the declaration's own value, which the scan still reads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant