Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/watcher/src/ast/__tests__/parseIntentFromReactAst.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement, ButtonProps>((props, ref) => {
return <button ref={ref} {...props}>Click me</button>;
});`;
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 <input ref={ref} />;
});
`;
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 <div>Card body</div>;
});
`;
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 <div>not a component</div>;
});
`;
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
// =============================================================================
Expand Down
24 changes: 23 additions & 1 deletion packages/watcher/src/ast/parseIntentFromReactAst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
}
}
}
Comment on lines +258 to +276

// 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;
Expand Down
Loading