diff --git a/packages/watcher/src/ast/__tests__/parseIntentFromReactAst.test.ts b/packages/watcher/src/ast/__tests__/parseIntentFromReactAst.test.ts index 2d16029..49f828e 100644 --- a/packages/watcher/src/ast/__tests__/parseIntentFromReactAst.test.ts +++ b/packages/watcher/src/ast/__tests__/parseIntentFromReactAst.test.ts @@ -169,6 +169,71 @@ describe('parseIntentFromReactAst - Component Extraction', () => { }); }); +// ============================================================================= +// WRAPPED COMPONENT EXTRACTION TESTS (forwardRef / memo) +// ============================================================================= + +describe('parseIntentFromReactAst - Wrapped Component Extraction', () => { + it('should extract a React.forwardRef-wrapped component', () => { + // Dominant shadcn/ui shape. loc spans the whole VariableDeclaration. + const code = `import * as React from 'react'; +export const Button = React.forwardRef((props, ref) => { + return ; +});`; + const report = parseIntentFromReactAst(code, 'Button.tsx'); + + expect(report.components).toHaveLength(1); + const button = report.components[0]; + expect(button.componentName).toBe('Button'); + expect(button.isExported).toBe(true); + expect(button.componentKey).toBe('Button'); + expect(button.loc.startLine).toBe(2); + expect(button.loc.endLine).toBe(4); + // JSX inside the wrapped render fn is still analyzed + expect(button.jsxTextLiterals.map((t) => t.text)).toContain('Click me'); + }); + + it('should extract a bare forwardRef-wrapped component', () => { + const code = ` + import { forwardRef } from 'react'; + export const Input = forwardRef((props, ref) => { + return ; + }); + `; + const report = parseIntentFromReactAst(code, 'test.tsx'); + + expect(report.components).toHaveLength(1); + expect(report.components[0].componentName).toBe('Input'); + expect(report.components[0].isExported).toBe(true); + }); + + it('should extract a memo-wrapped component', () => { + const code = ` + import { memo } from 'react'; + export const Card = memo(() => { + return
Card body
; + }); + `; + const report = parseIntentFromReactAst(code, 'test.tsx'); + + expect(report.components).toHaveLength(1); + expect(report.components[0].componentName).toBe('Card'); + expect(report.components[0].isExported).toBe(true); + }); + + it('should not treat unrelated call-expression initializers as components', () => { + const code = ` + export const Config = createConfig(() => { + return
not a component
; + }); + `; + const report = parseIntentFromReactAst(code, 'test.tsx'); + + // createConfig is not forwardRef/memo, so Config is not a component shape + expect(report.components).toHaveLength(0); + }); +}); + // ============================================================================= // JSX TEXT LITERAL TESTS // ============================================================================= diff --git a/packages/watcher/src/ast/parseIntentFromReactAst.ts b/packages/watcher/src/ast/parseIntentFromReactAst.ts index a15e975..a2bb62f 100644 --- a/packages/watcher/src/ast/parseIntentFromReactAst.ts +++ b/packages/watcher/src/ast/parseIntentFromReactAst.ts @@ -188,6 +188,8 @@ interface ComponentBounds { * - export const Foo = () => {} * - function Foo() {} with separate export * - const Foo = () => {} with separate export + * - const Foo = React.forwardRef((props, ref) => {}) / forwardRef(...) + * - const Foo = memo(() => {}) / React.memo(...) */ function collectComponents(ast: t.File): ComponentBounds[] { const components: ComponentBounds[] = []; @@ -253,8 +255,28 @@ function collectComponents(ast: t.File): ComponentBounds[] { const init = path.node.init; if (!init) return; + // Unwrap React.forwardRef(...) / memo(...) wrappers so that the dominant + // shadcn/ui shape `const Button = React.forwardRef((props, ref) => ...)` + // is recognized. We look at the first argument when it is an arrow/function + // expression, then fall through to the regular check below. + let unwrapped: t.Node = init; + if (t.isCallExpression(init)) { + const callee = init.callee; + const calleeName = t.isIdentifier(callee) + ? callee.name + : t.isMemberExpression(callee) && t.isIdentifier(callee.property) + ? callee.property.name + : undefined; + if ((calleeName === 'forwardRef' || calleeName === 'memo') && init.arguments.length > 0) { + const arg = init.arguments[0]; + if (t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) { + unwrapped = arg; + } + } + } + // Check if it's a function expression or arrow function - if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) { + if (t.isArrowFunctionExpression(unwrapped) || t.isFunctionExpression(unwrapped)) { // Use the VariableDeclaration (parent of VariableDeclarator) for location // path.parent is the VariableDeclaration const parent = path.parent;