Skip to content

fix(react): a typed styled component is a component, not a constant - #2429

Merged
colbymchenry merged 8 commits into
mainfrom
claude/loving-mcnulty-156108
Oct 7, 2026
Merged

colbymchenry merged 8 commits into
mainfrom
claude/loving-mcnulty-156108

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

const Wrapper = styled.div<WrapperProps> + a template literal was indexed as a constant, while styled.div + a template and styled.div<{ open: boolean }> + a template were already components (#841). A constant is skipped by the JSX-render synthesizer, so <Wrapper> got no render edge, or got bound to a same-named component in another file. This PR recognizes the typed form as a component too, in both the TS extractor and the kernel.

Why it happened

tree-sitter-typescript's tagged-template call (a call_expression whose arguments is a template_string) has no type_arguments field. When the type argument also reads as an expression, the initializer parses as comparisons instead (same tree on the tsx and typescript grammars, and no parse error):

styled.div<WrapperProps>`…`              (styled.div < WrapperProps) > `…`
styled(Plain)<Props>`…`                  (styled(Plain) < Props) > `…`
styled.div<{ animation: A | null }>`…`   (styled.div < {animation: A | null}) > `…`    the type is an object literal
styled.div<A | B>`…`                     (styled.div < A) | (B > `…`)
styled.div<Partial<P>>`…`                styled.div < (Partial < (P >> `…`))

A type argument that can't be an expression (<{ open: boolean }> in most contexts, <{ a?: T }>, <keyof T>) error-recovers into call_expression(non_null_expression(instantiation_expression …) MISSING "!"). Its callee text starts with styled, which is why that form already worked. The recovery makes the file erroring, so the kernel defers it to wasm.

Change

reactComponentHoc (src/extraction/tree-sitter.ts) and react_component_hoc (codegraph-kernel/src/tsjs/extractors.rs) also take that comparison chain:

  • the leftmost operand is the styled… tag, against a <;
  • the rightmost operand is the template, against a > (>>/>>> when the type argument ends in its own <…>);
  • operators in between belong to the type argument.

The PascalCase gate is unchanged. css<P>, createGlobalStyle<P>, keyframes<P>, styled.length < LIMIT > 2 and lowercase consts stay constants.

Validation

Tests

  • react-hoc-component.test.ts: 3 new tests.
    • 11 typed shapes from outline, including .attrs(…)<P>, .withConfig().attrs<P>()<P>, <A | B>, <A & {…}> and <Partial<…>>, are components.
    • <Wrapper> gets jsx-render callers from its own file and from an importer.
    • A precision guard.
  • kernel-tsjs-parity.test.ts: kernel == wasm for these shapes, in ts and tsx.
  • Red/green: the 4 positive tests fail on main and pass with the fix.

Full suite: the parallel run on a saturated box (100% CPU, 70 to 200 node processes) failed in 47 files, every failure a 5 s/10 s timeout or EBUSY. Rerun serially with long timeouts, those files passed except 14 tests in 7 daemon/MCP-lifecycle, writer-lock, query-pool and worktree files. Those 7 files were then run back to back on main's source (61f8b09) and on this branch under the same load. main failed 4. This branch failed 3, all of which also fail on main: daemon-pid-reuse (EBUSY), the #1820 function-ref case (60 s timeout) and mcp-writer-lock. None of these touch TSX extraction.

Kernel parity (scripts/kernel-parity.mjs): 0 diffs on every sweep, and the deferral counts are identical to main's.

sweep files at byte parity
outline 2439/2550
excalidraw 700/705
SigNoz frontend 4300/4316
this repo's src/ + __tests__/ 845/861

A/B on real repos. Each arm ran a fresh codegraph init -y on the kernel path: main 9a1d4bd (with #2442 and #2433) against this branch. Dumps come from scripts/dump-graph.mjs. Edges are compared by kind-free endpoint labels, because a node id embeds its kind and every edge touching a reclassified node is re-keyed.

repo constant → component jsx-render edges other edges unresolved refs
outline/outline 28 57 new, all correct; 35 moved from a wrong namesake to the right component 19 same-file value refs became jsx-render edges; 38 came out of the 28 initializers; 1 flipped (see 3 below) 61 fewer, all failed
SigNoz frontend (sparse frontend/src) 24 16 new and 1 corrected, all correct; 35 new wrong edges (see 2 below) 2 value refs fewer 24 fewer, all failed
excalidraw, bulletproof, matx, mantis, minimal-kit, next-saas, proshop, t3-turbo 0 byte-identical

Every changed jsx-render edge was checked against the source. The tag must sit inside the rendering function, and the target must be the file's own module-level declaration of the name or what its import names (tsconfig aliases, baseUrl paths, index files and one re-export hop followed).

New callers on outline: NudeButton 22, HStack 18, VStack 8, Key 2, and one each for RealButton, Main, GroupWrapper, FieldSet, ContentsContainer, CloseAction and AvatarPresence.

Corrected on outline:

  • 15 <Text> tags imported from @shared/components/Text had been bound to EditableTitle.tsx's local Text.
  • 6 <Emoji> tags had been bound to the Emoji model class.
  • <Wrapper>, <Background>, <Container>, <Content>, <Title>, <StyledContent> and <EditorContainer> in files that declare them had gone to another file's namesake.
  • <MenuButton> and the EditorContainer imported from @shared/editor/components/Styles had gone to the wrong file.

The first run, on main 61f8b09, gave the same numbers plus 3 edges that moved between two wrong targets: a function-local const Content = … alias in Menu/index.tsx (2) and the type argument in SuggestionsMenuProps<Emoji> (1). #2442 has since removed those edges from both arms.

Dead code: the report is identical on outline (47) and SigNoz (10). The reclassified consts become candidates, and every one of them is excluded as exported or mentioned.

Known side effects

  1. The 28 consts lose the edges their initializer walk made: 38 on outline.
    • What goes: 25 calls to theme helpers like s() and borderRadius(); 4 function refs to the wrapped base, such as NudeButton → ActionButton; 6 value refs to CSS mixin constants; 2 exact-match calls and 1 import ref.
    • Why this is consistent: forwardRef components get no JSX render edges — callers/impact silently return empty #841 mints every styled component bodyless, so untyped styled(...) components (about 776 on outline) never had these edges.
    • Follow-up filed: walk a wrapper's initializer under its component node.
  2. SigNoz gains 35 wrong render edges.
    • What happens: 34 <Modal> tags imported from antd, plus 1 renamed default import of components/Modal.tsx, now bind to the project's only Modal (styled(ModalComponent)<Props>).
    • Cause: the jsx-render synthesizer's jsxChild returns a lone namesake without checking that the file imports the name from a package.
    • This rule is already the biggest error source on main: 971 of SigNoz's 3,379 jsx-render edges, for example 431 antd <Button> tags bound to an untyped styled(Link) named Button.
    • Follow-up filed with the evidence. An earlier one from fix(react): a component a file declares itself as a value is what its JSX renders #2420's session covers mantis's <Link> from @mui/material.
  3. One type reference flipped on outline. EmojiMenu.tsx's SuggestionsMenuProps<Emoji> names that file's own type Emoji. It now resolves through React Pattern 1's component-directory preference to the new Emoji component, where exact-match had bound it to the local type alias. Pattern 1 never looks at the referencing file's own type declarations; on main only the ambiguity guard hid this. Follow-up filed.

Overlap

#2420 (open) changes what the jsx-render synthesizer and Pattern 1 bind a name to: the referencing file's own value declarations. It doesn't touch extraction, so there is no textual overlap. Once both land, a typed styled const is a component the file declares itself. Whichever lands second should re-run the outline A/B.

🤖 Generated with Claude Code

colbymchenry and others added 8 commits October 7, 2026 05:08
`const Wrapper = styled.div<WrapperProps>` + a template literal came out as a
`constant`, so the JSX-render synthesizer skipped it: `<Wrapper>` got no
render edge, or bound to a same-named component in another file.
tree-sitter's tagged-template call takes no type arguments, so when the type
argument also reads as an expression the initializer parses as comparisons,
`(styled.div < WrapperProps) > template`, not as a call. (A type argument
that can't be an expression, `<{ open: boolean }>`, error-recovers into a
call on the tag, which #841 already took.)

reactComponentHoc now also takes that comparison chain: the leftmost operand
is the `styled` tag against a `<`, the rightmost is the template against a
`>` (`>>` when the type argument ends in its own `<...>`), and operators in
between belong to the type argument (`<A & B>`). Mirrored in the kernel's
TS/JS walker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 78f5c88 into main Oct 7, 2026
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