Skip to content

Commit 2a76067

Browse files
async3619claude
andauthored
fix(react): resolve builtin elements from namespace-imported styled-components (#110)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent b38bf71 commit 2a76067

8 files changed

Lines changed: 171 additions & 1 deletion

File tree

e2e/react.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,77 @@ describe('analyzeReactUsage', () => {
510510
})
511511
})
512512

513+
it('resolves builtin elements from namespace-imported styled-components', () => {
514+
const graph = analyzeReactUsage('src/namespace-styled-entry.tsx', {
515+
cwd: fixtureDirectory,
516+
includeBuiltins: true,
517+
})
518+
519+
const output = printReactUsageTree(graph, {
520+
color: false,
521+
})
522+
const jsonTree = graphToSerializableReactTree(graph)
523+
524+
expect(output).toContain(
525+
'<FeaturePage /> [component] (src/namespace-styled-entry.tsx)',
526+
)
527+
expect(output).toContain(
528+
'<Section /> as Styled.Section [component] (src/components/FeatureSection.styled.tsx)',
529+
)
530+
expect(output).toContain(
531+
'<Container /> as Styled.Container [component] (src/components/FeatureSection.styled.tsx)',
532+
)
533+
expect(output).toContain('<section> [builtin] (html)')
534+
expect(output).toContain('<div> [builtin] (html)')
535+
536+
expect(jsonTree).toMatchObject({
537+
entries: [
538+
expect.objectContaining({
539+
referenceName: 'FeaturePage',
540+
node: expect.objectContaining({
541+
name: 'FeaturePage',
542+
symbolKind: 'component',
543+
}),
544+
}),
545+
],
546+
roots: [
547+
expect.objectContaining({
548+
name: 'FeaturePage',
549+
usages: expect.arrayContaining([
550+
expect.objectContaining({
551+
node: expect.objectContaining({
552+
name: 'Section',
553+
symbolKind: 'component',
554+
usages: expect.arrayContaining([
555+
expect.objectContaining({
556+
node: expect.objectContaining({
557+
name: 'section',
558+
symbolKind: 'builtin',
559+
}),
560+
}),
561+
]),
562+
}),
563+
}),
564+
expect.objectContaining({
565+
node: expect.objectContaining({
566+
name: 'Container',
567+
symbolKind: 'component',
568+
usages: expect.arrayContaining([
569+
expect.objectContaining({
570+
node: expect.objectContaining({
571+
name: 'div',
572+
symbolKind: 'builtin',
573+
}),
574+
}),
575+
]),
576+
}),
577+
}),
578+
]),
579+
}),
580+
],
581+
})
582+
})
583+
513584
it('prints multiple React entry locations when the entry file renders more than one root', () => {
514585
const graph = analyzeReactUsage('src/multi-entry.tsx', {
515586
cwd: fixtureDirectory,

src/analyzers/react/bindings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ function getImportBinding(
117117
}
118118
}
119119

120+
if (specifier.type === 'ImportNamespaceSpecifier') {
121+
return {
122+
localName: specifier.local.name,
123+
importedName: '*',
124+
sourceSpecifier,
125+
...(sourcePath === undefined ? {} : { sourcePath }),
126+
}
127+
}
128+
120129
return undefined
121130
}
122131

src/analyzers/react/entries.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getComponentReferenceName,
1010
getCreateElementComponentReferenceName,
1111
getHookReferenceName,
12+
getMemberExpressionComponentReferenceName,
1213
isNode,
1314
} from './walk.js'
1415

@@ -73,7 +74,9 @@ function collectNodeEntryUsages(
7374
let nextHasComponentAncestor = hasComponentAncestor
7475

7576
if (node.type === 'JSXElement') {
76-
const referenceName = getComponentReferenceName(node)
77+
const referenceName =
78+
getComponentReferenceName(node) ??
79+
getMemberExpressionComponentReferenceName(node)
7780
if (referenceName !== undefined) {
7881
if (!hasComponentAncestor) {
7982
addPendingReactUsageEntry(

src/analyzers/react/references.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ export function resolveReactReference(
1515
return getBuiltinNodeId(name)
1616
}
1717

18+
const dotIndex = name.indexOf('.')
19+
if (dotIndex !== -1) {
20+
return resolveNamespaceMemberReference(
21+
fileAnalysis,
22+
fileAnalyses,
23+
name.slice(0, dotIndex),
24+
name.slice(dotIndex + 1),
25+
kind,
26+
)
27+
}
28+
1829
const localSymbol = fileAnalysis.allSymbolsByName.get(name)
1930
if (localSymbol !== undefined && localSymbol.kind === kind) {
2031
return localSymbol.id
@@ -50,6 +61,36 @@ export function resolveReactReference(
5061
return targetId
5162
}
5263

64+
function resolveNamespaceMemberReference(
65+
fileAnalysis: FileAnalysis,
66+
fileAnalyses: ReadonlyMap<string, FileAnalysis>,
67+
namespaceName: string,
68+
propertyName: string,
69+
kind: ReactSymbolKind,
70+
): string | undefined {
71+
const importBinding = fileAnalysis.importsByLocalName.get(namespaceName)
72+
if (
73+
importBinding === undefined ||
74+
importBinding.importedName !== '*' ||
75+
importBinding.sourcePath === undefined
76+
) {
77+
return undefined
78+
}
79+
80+
const sourceFileAnalysis = fileAnalyses.get(importBinding.sourcePath)
81+
if (sourceFileAnalysis === undefined) {
82+
return undefined
83+
}
84+
85+
return resolveExportedSymbol(
86+
sourceFileAnalysis,
87+
propertyName,
88+
kind,
89+
fileAnalyses,
90+
new Set<string>(),
91+
)
92+
}
93+
5394
function resolveExportedSymbol(
5495
fileAnalysis: FileAnalysis,
5596
exportName: string,

src/analyzers/react/usage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getComponentReferenceName,
55
getCreateElementComponentReferenceName,
66
getHookReferenceName,
7+
getMemberExpressionComponentReferenceName,
78
getStyledBuiltinReferenceName,
89
getStyledComponentReferenceName,
910
walkReactUsageTree,
@@ -18,6 +19,11 @@ export function analyzeSymbolUsages(
1819
const name = getComponentReferenceName(node)
1920
if (name !== undefined) {
2021
symbol.componentReferences.add(name)
22+
} else {
23+
const memberName = getMemberExpressionComponentReferenceName(node)
24+
if (memberName !== undefined) {
25+
symbol.componentReferences.add(memberName)
26+
}
2127
}
2228

2329
if (includeBuiltins) {

src/analyzers/react/walk.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ export function getComponentReferenceName(
124124
return name !== undefined && isComponentName(name) ? name : undefined
125125
}
126126

127+
export function getMemberExpressionComponentReferenceName(
128+
node: JSXElement,
129+
): string | undefined {
130+
const name = node.openingElement.name
131+
if (name.type !== 'JSXMemberExpression') {
132+
return undefined
133+
}
134+
135+
if (name.object.type !== 'JSXIdentifier') {
136+
return undefined
137+
}
138+
139+
const objectName = name.object.name
140+
const propertyName = name.property.name
141+
142+
if (isComponentName(objectName)) {
143+
return `${objectName}.${propertyName}`
144+
}
145+
146+
return undefined
147+
}
148+
127149
export function getBuiltinReferenceName(node: JSXElement): string | undefined {
128150
const name = getJsxName(node.openingElement.name)
129151
return name !== undefined && isIntrinsicElementName(name) ? name : undefined
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import styled from 'styled-components'
2+
3+
export const Section = styled.section`
4+
display: flex;
5+
`
6+
7+
export const Container = styled.div`
8+
padding: 1rem;
9+
`
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Styled from './components/FeatureSection.styled'
2+
3+
export function FeaturePage() {
4+
return (
5+
<Styled.Section>
6+
<Styled.Container>Hello</Styled.Container>
7+
</Styled.Section>
8+
)
9+
}

0 commit comments

Comments
 (0)