From 6b2c6126fdd9aaa8cf1d1a8ab3266d166ad33bef Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 05:08:52 -0500 Subject: [PATCH 1/2] fix(react): a typed styled component is a component, not a constant `const Wrapper = styled.div` + a template literal came out as a `constant`, so the JSX-render synthesizer skipped it: `` 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 (``). Mirrored in the kernel's TS/JS walker. Co-Authored-By: Claude Opus 5.5 --- __tests__/kernel-tsjs-parity.test.ts | 27 +++++++ __tests__/react-hoc-component.test.ts | 94 +++++++++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 32 +++++++++ src/extraction/tree-sitter.ts | 40 ++++++++++- 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 57fec8649d..9032f27ea2 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -314,6 +314,33 @@ export function second() { return probe(); } ])); }); + it.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], + ] as const)('typed styled tags are components, parsed as comparisons or not: %s', (ext, language) => { + // `styled.div\`…\`` parses as `(styled.div < Props) > \`…\``; the + // type argument's own operators sit between (``, `>`). + const result = assertParity(`styles.${ext}`, ` +import styled, { css } from 'styled-components'; +import { s } from './theme'; +type Props = { align: 'start' | 'end' }; +const Plain = styled.div\`color: red;\`; +export const Wrapper = styled.div\`color: \${(p) => s(p.align)};\`; +const CloseAction = styled.div<{ animation: Animation | null }>\`top: 0;\`; +const Content = styled(Plain)\`padding: 4px;\`; +const NudeButton = styled(Plain).attrs((props: Props) => ({ type: "button" }))\`\`; +const Either = styled.div\`color: red;\`; +const Nested = styled.div>\`color: red;\`; +const Mixin = css\`color: red;\`; +const Compared = styled.length < LIMIT > 2; +const lowerCase = styled.div\`color: red;\`; +`, language); + const kind = (name: string) => result.nodes.filter((n) => n.name === name).map((n) => n.kind); + for (const name of ['Plain', 'Wrapper', 'CloseAction', 'Content', 'NudeButton', 'Either', 'Nested']) { + expect(kind(name), name).toEqual(['component']); + } + for (const name of ['Mixin', 'Compared', 'lowerCase']) expect(kind(name), name).toEqual(['constant']); + }); + it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => { const file = path.join(FIXTURE_DIR, 'torture.tsx'); assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx'); diff --git a/__tests__/react-hoc-component.test.ts b/__tests__/react-hoc-component.test.ts index cac29b8363..41e371704f 100644 --- a/__tests__/react-hoc-component.test.ts +++ b/__tests__/react-hoc-component.test.ts @@ -60,6 +60,100 @@ export const Rewrapped = memo(Button); } }); + it('classifies typed styled consts as component nodes too', async () => { + // tree-sitter's tagged-template call takes no type arguments, so a type + // argument that also reads as an expression turns the initializer into + // comparisons: `(styled.div < WrapperProps) > \`…\``. These shapes come from + // outline/outline (FloatingToolbar's Wrapper and Background, Lightbox's + // CloseAction, NudeButton, HStack). + fs.writeFileSync( + path.join(dir, 'typed.tsx'), + `import styled from 'styled-components'; +import { s, depths } from './theme'; +type WrapperProps = { active: boolean }; +type Props = { align: 'start' | 'end' }; +const Plain = styled.div\`color: red;\`; +export const Wrapper = styled.div\` + z-index: \${depths.editorToolbar}; + color: \${(props) => (props.active ? s("accent") : s("text"))}; +\`; +const Background = styled.div<{ align: Props["align"] }>\`color: red;\`; +const CloseAction = styled.div<{ animation: Animation | null }>\`top: 0;\`; +const Content = styled(Plain)\`padding: 4px;\`; +const NudeButton = styled(Plain).attrs((props: Props) => ({ type: "button" }))\`width: 24px;\`; +export const HStack = styled(Plain) + .withConfig({ shouldForwardProp: (prop) => prop !== "spacing" }) + .attrs((props) => ({ align: props.align }))\`\`; +const Either = styled.div\`color: red;\`; +const Both = styled.span\`color: red;\`; +const Nested = styled.div>\`color: red;\`; +const Deeper = styled.div>>\`color: red;\`; +const Named = styled("figure")\`margin: 0;\`; +` + ); + const db = await index(); + for (const name of [ + 'Wrapper', 'Background', 'CloseAction', 'Content', 'NudeButton', 'HStack', + 'Either', 'Both', 'Nested', 'Deeper', 'Named', + ]) { + expect(kindsOf(db, name), `${name} should be a component`).toEqual(['component']); + } + }); + + it('gives a typed styled const its jsx-render callers', async () => { + fs.writeFileSync( + path.join(dir, 'toolbar.tsx'), + `import styled from 'styled-components'; +type WrapperProps = { active: boolean }; +export const Wrapper = styled.div\`opacity: 0;\`; +export function FloatingToolbar() { + return menu; +} +` + ); + fs.writeFileSync( + path.join(dir, 'page.tsx'), + `import { Wrapper } from './toolbar'; +export function Page() { + return ; +} +` + ); + const db = await index(); + const callers = db + .prepare( + `SELECT s.name caller FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + AND t.kind = 'component' AND t.name = 'Wrapper' + ORDER BY s.name` + ) + .all() + .map((r: any) => r.caller); + expect(callers).toEqual(['FloatingToolbar', 'Page']); + }); + + it('keeps other typed template tags and real comparisons constants (precision)', async () => { + fs.writeFileSync( + path.join(dir, 'tags.tsx'), + `import styled, { css, createGlobalStyle, keyframes } from 'styled-components'; +type Props = { open: boolean }; +export const Mixin = css\`color: red;\`; +export const GlobalStyle = createGlobalStyle\`body { margin: 0; }\`; +export const Fade = keyframes\`from { opacity: 0; }\`; +export const Styledish = styledLike\`color: red;\`; +export const Compared = styled.length < LIMIT > 2; +export const Untagged = styled.div < Props > other; +const lowerCase = styled.div\`color: red;\`; +` + ); + const db = await index(); + for (const name of ['Mixin', 'GlobalStyle', 'Fade', 'Styledish', 'Compared', 'Untagged', 'lowerCase']) { + expect(kindsOf(db, name), `${name} must stay a constant`).toEqual(['constant']); + } + }); + it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => { fs.writeFileSync( path.join(dir, 'button.tsx'), diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 461ae6d397..1c84f8c33a 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -103,6 +103,9 @@ impl<'t> Walker<'t> { /// inner is the inline render function, or None for `styled.x`/`memo(Ref)`. /// Outer None = not a component wrapper. fn react_component_hoc(&self, value: Node<'t>) -> Option>> { + if self.is_typed_styled_template(value) { + return Some(None); + } if value.kind() != "call_expression" { return None; } @@ -128,6 +131,35 @@ impl<'t> Walker<'t> { Some(inner) } + /// A typed styled tag, `styled.div` before a template literal, which + /// parses as comparisons: `(styled.div < Props) > template`. The leftmost + /// operand is the `styled` tag against a `<`; the rightmost is the + /// template_string against a `>` (`>>` when the type argument ends in its + /// own `<…>`). Mirrors TreeSitterExtractor.isTypedStyledTemplate. + fn is_typed_styled_template(&self, value: Node<'t>) -> bool { + if value.kind() != "binary_expression" { + return false; + } + let (mut tag_parent, mut tag) = (value, value.child_by_field_name("left")); + while let Some(t) = tag.filter(|t| t.kind() == "binary_expression") { + (tag_parent, tag) = (t, t.child_by_field_name("left")); + } + let (mut template_parent, mut template) = (value, value.child_by_field_name("right")); + while let Some(t) = template.filter(|t| t.kind() == "binary_expression") { + (template_parent, template) = (t, t.child_by_field_name("right")); + } + let (Some(tag), Some(template)) = (tag, template) else { + return false; + }; + let operator = |n: Node<'t>| n.child_by_field_name("operator").map(|o| o.kind()).unwrap_or(""); + let closing = operator(template_parent); + template.kind() == "template_string" + && operator(tag_parent) == "<" + && !closing.is_empty() + && closing.bytes().all(|b| b == b'>') + && util::styled_callee().is_match(self.text(tag)) + } + fn extract_react_component_node( &mut self, name: &str, diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 997d735170..4b94a76a3b 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1947,7 +1947,8 @@ export class TreeSitterExtractor { /** * Detect a React component declared via an HOC wrapper whose result is itself a * component: `forwardRef(...)`, `memo(...)`, `React.forwardRef/memo(...)`, and - * styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``. These + * styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``, typed + * (`styled.tag\`…\``) or not. These * initializers are a call / tagged-template (not a bare arrow), so the const is * otherwise classified `constant` — and a constant is skipped by both the * JSX-render edge synthesizer and component resolution, so `