diff --git a/e2e/react.test.ts b/e2e/react.test.ts index 0ce0a06..4cf0215 100644 --- a/e2e/react.test.ts +++ b/e2e/react.test.ts @@ -510,6 +510,77 @@ describe('analyzeReactUsage', () => { }) }) + it('resolves builtin elements from namespace-imported styled-components', () => { + const graph = analyzeReactUsage('src/namespace-styled-entry.tsx', { + cwd: fixtureDirectory, + includeBuiltins: true, + }) + + const output = printReactUsageTree(graph, { + color: false, + }) + const jsonTree = graphToSerializableReactTree(graph) + + expect(output).toContain( + ' [component] (src/namespace-styled-entry.tsx)', + ) + expect(output).toContain( + '
as Styled.Section [component] (src/components/FeatureSection.styled.tsx)', + ) + expect(output).toContain( + ' as Styled.Container [component] (src/components/FeatureSection.styled.tsx)', + ) + expect(output).toContain('
[builtin] (html)') + expect(output).toContain('
[builtin] (html)') + + expect(jsonTree).toMatchObject({ + entries: [ + expect.objectContaining({ + referenceName: 'FeaturePage', + node: expect.objectContaining({ + name: 'FeaturePage', + symbolKind: 'component', + }), + }), + ], + roots: [ + expect.objectContaining({ + name: 'FeaturePage', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'Section', + symbolKind: 'component', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'section', + symbolKind: 'builtin', + }), + }), + ]), + }), + }), + expect.objectContaining({ + node: expect.objectContaining({ + name: 'Container', + symbolKind: 'component', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'div', + symbolKind: 'builtin', + }), + }), + ]), + }), + }), + ]), + }), + ], + }) + }) + it('prints multiple React entry locations when the entry file renders more than one root', () => { const graph = analyzeReactUsage('src/multi-entry.tsx', { cwd: fixtureDirectory, diff --git a/src/analyzers/react/bindings.ts b/src/analyzers/react/bindings.ts index 41cd176..87f41cc 100644 --- a/src/analyzers/react/bindings.ts +++ b/src/analyzers/react/bindings.ts @@ -117,6 +117,15 @@ function getImportBinding( } } + if (specifier.type === 'ImportNamespaceSpecifier') { + return { + localName: specifier.local.name, + importedName: '*', + sourceSpecifier, + ...(sourcePath === undefined ? {} : { sourcePath }), + } + } + return undefined } diff --git a/src/analyzers/react/entries.ts b/src/analyzers/react/entries.ts index fdfd47d..c45802e 100644 --- a/src/analyzers/react/entries.ts +++ b/src/analyzers/react/entries.ts @@ -9,6 +9,7 @@ import { getComponentReferenceName, getCreateElementComponentReferenceName, getHookReferenceName, + getMemberExpressionComponentReferenceName, isNode, } from './walk.js' @@ -73,7 +74,9 @@ function collectNodeEntryUsages( let nextHasComponentAncestor = hasComponentAncestor if (node.type === 'JSXElement') { - const referenceName = getComponentReferenceName(node) + const referenceName = + getComponentReferenceName(node) ?? + getMemberExpressionComponentReferenceName(node) if (referenceName !== undefined) { if (!hasComponentAncestor) { addPendingReactUsageEntry( diff --git a/src/analyzers/react/references.ts b/src/analyzers/react/references.ts index 817333c..2b29707 100644 --- a/src/analyzers/react/references.ts +++ b/src/analyzers/react/references.ts @@ -15,6 +15,17 @@ export function resolveReactReference( return getBuiltinNodeId(name) } + const dotIndex = name.indexOf('.') + if (dotIndex !== -1) { + return resolveNamespaceMemberReference( + fileAnalysis, + fileAnalyses, + name.slice(0, dotIndex), + name.slice(dotIndex + 1), + kind, + ) + } + const localSymbol = fileAnalysis.allSymbolsByName.get(name) if (localSymbol !== undefined && localSymbol.kind === kind) { return localSymbol.id @@ -50,6 +61,36 @@ export function resolveReactReference( return targetId } +function resolveNamespaceMemberReference( + fileAnalysis: FileAnalysis, + fileAnalyses: ReadonlyMap, + namespaceName: string, + propertyName: string, + kind: ReactSymbolKind, +): string | undefined { + const importBinding = fileAnalysis.importsByLocalName.get(namespaceName) + if ( + importBinding === undefined || + importBinding.importedName !== '*' || + importBinding.sourcePath === undefined + ) { + return undefined + } + + const sourceFileAnalysis = fileAnalyses.get(importBinding.sourcePath) + if (sourceFileAnalysis === undefined) { + return undefined + } + + return resolveExportedSymbol( + sourceFileAnalysis, + propertyName, + kind, + fileAnalyses, + new Set(), + ) +} + function resolveExportedSymbol( fileAnalysis: FileAnalysis, exportName: string, diff --git a/src/analyzers/react/usage.ts b/src/analyzers/react/usage.ts index 969fbba..f7b33bb 100644 --- a/src/analyzers/react/usage.ts +++ b/src/analyzers/react/usage.ts @@ -4,6 +4,7 @@ import { getComponentReferenceName, getCreateElementComponentReferenceName, getHookReferenceName, + getMemberExpressionComponentReferenceName, getStyledBuiltinReferenceName, getStyledComponentReferenceName, walkReactUsageTree, @@ -18,6 +19,11 @@ export function analyzeSymbolUsages( const name = getComponentReferenceName(node) if (name !== undefined) { symbol.componentReferences.add(name) + } else { + const memberName = getMemberExpressionComponentReferenceName(node) + if (memberName !== undefined) { + symbol.componentReferences.add(memberName) + } } if (includeBuiltins) { diff --git a/src/analyzers/react/walk.ts b/src/analyzers/react/walk.ts index 10da610..6f2ae03 100644 --- a/src/analyzers/react/walk.ts +++ b/src/analyzers/react/walk.ts @@ -124,6 +124,28 @@ export function getComponentReferenceName( return name !== undefined && isComponentName(name) ? name : undefined } +export function getMemberExpressionComponentReferenceName( + node: JSXElement, +): string | undefined { + const name = node.openingElement.name + if (name.type !== 'JSXMemberExpression') { + return undefined + } + + if (name.object.type !== 'JSXIdentifier') { + return undefined + } + + const objectName = name.object.name + const propertyName = name.property.name + + if (isComponentName(objectName)) { + return `${objectName}.${propertyName}` + } + + return undefined +} + export function getBuiltinReferenceName(node: JSXElement): string | undefined { const name = getJsxName(node.openingElement.name) return name !== undefined && isIntrinsicElementName(name) ? name : undefined diff --git a/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx b/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx new file mode 100644 index 0000000..0753a82 --- /dev/null +++ b/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components' + +export const Section = styled.section` + display: flex; +` + +export const Container = styled.div` + padding: 1rem; +` diff --git a/test/fixtures/react-mode/src/namespace-styled-entry.tsx b/test/fixtures/react-mode/src/namespace-styled-entry.tsx new file mode 100644 index 0000000..9d7567e --- /dev/null +++ b/test/fixtures/react-mode/src/namespace-styled-entry.tsx @@ -0,0 +1,9 @@ +import * as Styled from './components/FeatureSection.styled' + +export function FeaturePage() { + return ( + + Hello + + ) +}