[WEB-5459] feat(codemods): add function declaration transformer with tests (#8137)

- Add jscodeshift-based codemod to convert arrow function components to function declarations
- Support React.FC, observer-wrapped, and forwardRef components
- Include comprehensive test suite covering edge cases
- Add npm script to run transformer across codebase
- Target only .tsx files in source directories, excluding node_modules and declaration files

* [WEB-5459] chore: updates after running codemod

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Aaron 2025-11-20 19:09:40 +07:00 committed by GitHub
parent 90866fb925
commit 83fdebf64d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1771 changed files with 17003 additions and 13856 deletions

View file

@ -0,0 +1,5 @@
{
"printWidth": 80,
"tabWidth": 2,
"trailingComma": "es5"
}

View file

@ -0,0 +1,483 @@
import {
API,
FileInfo,
Options,
TSTypeReference,
JSCodeshift,
Identifier,
BlockStatement,
VariableDeclarator,
Expression,
Pattern,
SpreadElement,
JSXNamespacedName,
ASTNode,
Node,
FunctionDeclaration,
} from "jscodeshift";
const COMPONENT_TYPE_NAMES = new Set([
"FC",
"FunctionComponent",
"VFC",
"VoidFunctionComponent",
]);
const COMPONENT_NAME_PATTERN = /^[A-Z]/;
function isReactComponentType(typeReference: TSTypeReference, j: JSCodeshift) {
const typeName = typeReference.typeName;
if (!typeName) {
return false;
}
if (j.Identifier.check(typeName)) {
return COMPONENT_TYPE_NAMES.has(typeName.name);
}
if (
j.TSQualifiedName.check(typeName) &&
j.Identifier.check(typeName.left) &&
j.Identifier.check(typeName.right)
) {
return (
typeName.left.name === "React" &&
COMPONENT_TYPE_NAMES.has(typeName.right.name)
);
}
return false;
}
function isComponentNameIdentifier(identifier: Identifier | null | undefined) {
if (!identifier) {
return false;
}
return COMPONENT_NAME_PATTERN.test(identifier.name);
}
function addComments(target: Node, comments: NonNullable<Node["comments"]>) {
if (!comments || comments.length === 0) {
return;
}
target.comments ||= [];
target.comments.push(...comments);
}
function copyOuterComments(source: Node, target: Node, j: JSCodeshift) {
if (!j.Node.check(source) || !j.Node.check(target) || !source.comments) {
return;
}
const outerComments = source.comments.filter((c) => c.leading || c.trailing);
addComments(target, outerComments);
}
function ensureParamType(
param: Pattern,
propsType: ASTNode | null | undefined,
j: JSCodeshift
) {
if (!j.Pattern.check(param)) {
return;
}
if (!("typeAnnotation" in param)) {
return;
}
if (!propsType) {
return;
}
if (j.TSTypeReference.check(propsType) && propsType.typeName) {
param.typeAnnotation = j.tsTypeAnnotation(
propsType.typeParameters
? j.tsTypeReference(propsType.typeName, propsType.typeParameters)
: j.tsTypeReference(propsType.typeName)
);
return;
}
if (j.TSType.check(propsType)) {
// @ts-expect-error: jscodeshift types are too strict here
param.typeAnnotation = j.tsTypeAnnotation(propsType);
}
}
function toBlockBody(j: JSCodeshift, body: BlockStatement | Expression) {
if (j.BlockStatement.check(body)) {
return body;
}
// @ts-expect-error: jscodeshift types are too strict here
const returnStatement = j.returnStatement(body);
return j.blockStatement([returnStatement]);
}
function isFunction(node: Node, j: JSCodeshift) {
return (
j.ArrowFunctionExpression.check(node) || j.FunctionExpression.check(node)
);
}
function extractArrowFunction(
init: Expression | SpreadElement | JSXNamespacedName,
j: JSCodeshift
) {
if (isFunction(init, j)) {
return init;
}
// If it's a CallExpression like observer(() => {}), extract the arrow function
if (j.CallExpression.check(init)) {
const firstArg = init.arguments?.[0];
if (firstArg && isFunction(firstArg, j)) {
return firstArg;
}
}
return;
}
function extractPropsTypeFromWrapper(
init: Expression | SpreadElement | JSXNamespacedName,
j: JSCodeshift
) {
// If it's a CallExpression like observer<React.FC<Props>>((props) => {})
// Extract the Props type from React.FC<Props>
if (!j.CallExpression.check(init)) {
return;
}
if (!("typeParameters" in init)) {
return;
}
const typeParameters = init.typeParameters;
if (!j.TSTypeParameterInstantiation.check(typeParameters)) {
return;
}
const typeParam = typeParameters.params?.[0];
if (
!j.TSTypeReference.check(typeParam) ||
!isReactComponentType(typeParam, j)
) {
return;
}
// Extract the generic type from React.FC<PropsType>
return typeParam.typeParameters?.params?.[0];
}
function isReactForwardRef(
init: Expression | SpreadElement | JSXNamespacedName,
j: JSCodeshift
) {
if (!j.CallExpression.check(init)) {
return false;
}
const callee = init.callee;
// Check for React.forwardRef
if (
j.MemberExpression.check(callee) &&
j.Identifier.check(callee.object) &&
j.Identifier.check(callee.property)
) {
return (
callee.object.name === "React" && callee.property.name === "forwardRef"
);
}
// Check for forwardRef (imported directly)
if (j.Identifier.check(callee)) {
return callee.name === "forwardRef";
}
return false;
}
function extractForwardRefTypes(
init: Expression | SpreadElement | JSXNamespacedName,
j: JSCodeshift
) {
if (!isReactForwardRef(init, j)) {
return;
}
if (!("typeParameters" in init)) {
return;
}
const typeParameters = init.typeParameters;
// If no type parameters, we still want to apply default empty object for props
if (
!j.TSTypeParameterInstantiation.check(typeParameters) ||
typeParameters.params.length === 0
) {
return; // Let the default props type handling take care of it
}
const typeParams = typeParameters.params;
// React.forwardRef<ElementType, PropsType>
// If PropsType is not specified, use Record<string, unknown> to avoid ESLint errors
const [elementType] = typeParams;
if (!elementType) {
return;
}
const propsType =
typeParams.length >= 2 && typeParams[1]
? typeParams[1]
: j.tsTypeReference(
j.identifier("Record"),
j.tsTypeParameterInstantiation([
j.tsStringKeyword(),
j.tsUnknownKeyword(),
])
);
// Create React.ForwardedRef<ElementType> for the ref parameter
const refType = j.tsTypeReference(
j.tsQualifiedName(j.identifier("React"), j.identifier("ForwardedRef")),
j.tsTypeParameterInstantiation([elementType])
);
return { propsType, refType };
}
function isEmptyObjectType(type: ASTNode, j: JSCodeshift) {
return j.TSTypeLiteral.check(type) && type.members.length === 0;
}
function convertToFunction(
j: JSCodeshift,
declaration: VariableDeclarator,
init: Expression | SpreadElement | JSXNamespacedName,
propsType: ASTNode | null | undefined
) {
if (!j.Identifier.check(declaration.id)) {
throw new Error("Declaration id must be an identifier");
}
const componentName = declaration.id.name;
const arrowFn = extractArrowFunction(init, j);
if (!arrowFn) {
throw new Error("Expected ArrowFunctionExpression or FunctionExpression");
}
const params = arrowFn.params;
const body = toBlockBody(j, arrowFn.body);
const newFunction = j.functionDeclaration(
j.identifier(componentName),
params,
body
);
// Check if this is React.forwardRef and extract types for props and ref
const forwardRefTypes = extractForwardRefTypes(init, j);
if (forwardRefTypes) {
// Apply props type to first parameter
const [firstParam, secondParam] = newFunction.params;
if (j.Pattern.check(firstParam) && "typeAnnotation" in firstParam) {
ensureParamType(firstParam, forwardRefTypes.propsType, j);
}
// Apply ref type to second parameter
if (j.Pattern.check(secondParam) && "typeAnnotation" in secondParam) {
ensureParamType(secondParam, forwardRefTypes.refType, j);
}
} else if (newFunction.params.length > 0) {
const [firstParam] = newFunction.params;
if (firstParam) {
ensureParamType(firstParam, propsType, j);
}
} else if (propsType && !isEmptyObjectType(propsType, j)) {
// If there are no params but a non-empty propsType exists, add _props parameter
const propsParam = j.identifier("_props");
ensureParamType(propsParam, propsType, j);
newFunction.params.push(propsParam);
}
if (arrowFn.returnType) {
newFunction.returnType = arrowFn.returnType;
}
// Preserve type parameters (generics) from arrow function
if (arrowFn.typeParameters) {
newFunction.typeParameters = arrowFn.typeParameters;
}
newFunction.async = arrowFn.async;
newFunction.generator = arrowFn.generator;
return newFunction;
}
function containsJsx(j: JSCodeshift, body: ASTNode) {
return (
j(body).find(j.JSXElement).paths().length > 0 ||
j(body).find(j.JSXFragment).paths().length > 0
);
}
function toFunctionExpression(
j: JSCodeshift,
declaration: FunctionDeclaration
) {
const expression = j.functionExpression(
declaration.id,
declaration.params,
declaration.body,
declaration.generator,
declaration.async
);
expression.returnType = declaration.returnType;
expression.typeParameters = declaration.typeParameters;
return expression;
}
export default function transform(file: FileInfo, api: API, options: Options) {
const baseJ = api.jscodeshift;
const j =
typeof baseJ.withParser === "function" ? baseJ.withParser("tsx") : baseJ;
const root = j(file.source);
root
.find(j.VariableDeclaration)
.filter((path) => {
const [firstDeclaration] = path.node.declarations;
if (!j.VariableDeclarator.check(firstDeclaration)) {
return false;
}
if (
!j.Identifier.check(firstDeclaration.id) ||
!isComponentNameIdentifier(firstDeclaration.id)
) {
return false;
}
const init = firstDeclaration.init;
if (!init) {
return false;
}
const functionToCheck = extractArrowFunction(init, j);
if (!functionToCheck) {
return false;
}
if (file.path && !file.path.endsWith(".tsx")) {
if (!containsJsx(j, functionToCheck)) {
return false;
}
}
return true;
})
.forEach((path) => {
const [firstDeclaration] = path.node.declarations;
if (!j.VariableDeclarator.check(firstDeclaration)) {
return;
}
const init = firstDeclaration.init;
if (!init) {
return;
}
let typeAnnotation: ASTNode | null | undefined;
if (j.Identifier.check(firstDeclaration.id)) {
typeAnnotation = firstDeclaration.id.typeAnnotation?.typeAnnotation;
}
// Try to get props type from variable type annotation first
let propsType: ASTNode | undefined =
j.TSTypeReference.check(typeAnnotation) &&
isReactComponentType(typeAnnotation, j)
? typeAnnotation.typeParameters?.params?.[0]
: undefined;
// If no props type from variable annotation, try to extract from wrapper's type parameters
if (!propsType) {
propsType = extractPropsTypeFromWrapper(init, j);
}
const newFunction = convertToFunction(
j,
firstDeclaration,
init,
propsType
);
const originalNode = path.node;
// Check if init is wrapped in a call expression (e.g., observer(...))
const hasWrapper = j.CallExpression.check(init);
if (hasWrapper) {
// Preserve the wrapper by keeping it as a const assignment
// e.g., export const Foo = observer(() => {}) becomes export const Foo = observer(function Foo() {...})
// Convert function declaration to function expression for wrapping
const functionExpression = toFunctionExpression(j, newFunction);
const wrappedFunction = j.callExpression(init.callee, [
functionExpression,
]);
if (!j.Identifier.check(firstDeclaration.id)) {
return;
}
const newDeclarator = j.variableDeclarator(
j.identifier(firstDeclaration.id.name),
wrappedFunction
);
const newVarDecl = j.variableDeclaration("const", [newDeclarator]);
// Copy comments from original declaration to new function
copyOuterComments(originalNode, newVarDecl, j);
j(path).replaceWith(newVarDecl);
return;
}
// Copy outer comments from original declaration to new function
copyOuterComments(originalNode, newFunction, j);
// Copy comments from VariableDeclarator (e.g. export /* comment */ const Foo)
if (firstDeclaration.comments) {
addComments(newFunction, firstDeclaration.comments);
}
// Copy comments from arrow function
if (init.comments) {
addComments(newFunction, init.comments);
}
j(path).replaceWith(newFunction);
});
const quote = options.quote ?? '"';
const source = root.toSource({
quote,
});
return source;
}

View file

@ -0,0 +1,366 @@
# jscodeshift Instructions
## API Reference
### Core API
#### `jscodeshift`
The main function that returns the jscodeshift instance.
- **Parameters**: `source` (String)
- **Example**:
```javascript
const j = jscodeshift(sourceCode);
```
### Node Traversal APIs
#### `find`
Finds nodes that match the provided type.
- **Parameters**: `type` (String or Function)
- **Example**:
```javascript
const variableDeclarations = j.find(j.VariableDeclaration);
```
#### `findImportDeclarations`
Finds all ImportDeclarations optionally filtered by name.
- **Parameters**: `sourcePath` (String)
- **Example**:
```javascript
const routerImports = j.findImportDeclarations("react-router-dom");
```
#### `closestScope`
Finds the closest enclosing scope of a node.
- **Example**:
```javascript
const closestScopes = j.find(j.Identifier).closestScope();
```
#### `closest`
Finds the nearest parent node that matches the specified type.
- **Parameters**: `type` (String or Function)
- **Example**:
```javascript
const closestFunction = j.find(j.Identifier).closest(j.FunctionDeclaration);
```
#### `getVariableDeclarators`
Retrieves variable declarators from the current collection.
- **Parameters**: `callback` (Function)
- **Example**:
```javascript
const variableDeclarators = j.find(j.Identifier).getVariableDeclarators((path) => path.value.name);
```
#### `findVariableDeclarators`
Finds variable declarators by name.
- **Parameters**: `name` (String)
- **Example**:
```javascript
const variableDeclarators = j.findVariableDeclarators("a");
```
#### `filter`
Filters nodes based on a predicate function.
- **Parameters**: `predicate` (Function)
- **Example**:
```javascript
const constDeclarations = j.find(j.VariableDeclaration).filter((path) => path.node.kind === "const");
```
#### `forEach`
Iterates over each node in the collection.
- **Parameters**: `callback` (Function)
- **Example**:
```javascript
j.find(j.VariableDeclaration).forEach((path) => {
console.log(path.node);
});
```
#### `some`
Checks if at least one element in the collection passes the test.
- **Parameters**: `callback` (Function)
- **Example**:
```javascript
const hasVariableA = root.find(j.VariableDeclarator).some((path) => path.node.id.name === "a");
```
#### `every`
Checks if all elements in the collection pass the test.
- **Parameters**: `callback` (Function)
- **Example**:
```javascript
const allAreConst = root.find(j.VariableDeclaration).every((path) => path.node.kind === "const");
```
#### `map`
Maps each node in the collection to a new value.
- **Parameters**: `callback` (Function)
- **Example**:
```javascript
const variableNames = j.find(j.VariableDeclaration).map((path) => path.node.declarations.map((decl) => decl.id.name));
```
#### `size`
Returns the number of nodes in the collection.
- **Example**:
```javascript
const numberOfNodes = j.find(j.VariableDeclaration).size();
```
#### `length`
Returns the number of elements in the collection.
- **Example**:
```javascript
const varCount = root.find(j.VariableDeclarator).length;
```
#### `nodes`
Returns the AST nodes in the collection.
- **Example**:
```javascript
const nodes = j.find(j.VariableDeclaration).nodes();
```
#### `paths`
Returns the paths of the found nodes.
- **Example**:
```javascript
const paths = j.find(j.VariableDeclaration).paths();
```
#### `getAST`
Returns the root AST node of the collection.
- **Example**:
```javascript
const ast = root.getAST();
```
#### `get`
Gets the first node in the collection.
- **Example**:
```javascript
const firstVariableDeclaration = j.find(j.VariableDeclaration).get();
```
#### `at`
Navigates to a specific path in the AST.
- **Parameters**: `index` (Number)
- **Example**:
```javascript
const secondVariableDeclaration = j.find(j.VariableDeclaration).at(1);
```
#### `getTypes`
Returns the set of node types present in the collection.
- **Example**:
```javascript
const types = root.find(j.VariableDeclarator).getTypes();
```
#### `isOfType`
Checks if the node in the collection is of a specific type.
- **Parameters**: `type` (String)
- **Example**:
```javascript
const isVariableDeclarator = root.find(j.VariableDeclarator).at(0).isOfType("VariableDeclarator");
```
### Node Transformation APIs
#### `replaceWith`
Replaces the current node(s) with a new node.
- **Parameters**: `newNode` (Node or Function)
- **Example**:
```javascript
j.find(j.Identifier).replaceWith((path) => j.identifier(path.node.name.toUpperCase()));
```
#### `insertBefore`
Inserts a node before the current node.
- **Parameters**: `newNode` (Node)
- **Example**:
```javascript
j.find(j.FunctionDeclaration).insertBefore(j.expressionStatement(j.stringLiteral("Inserted before")));
```
#### `insertAfter`
Inserts a node after the current node.
- **Parameters**: `newNode` (Node)
- **Example**:
```javascript
j.find(j.FunctionDeclaration).insertAfter(j.expressionStatement(j.stringLiteral("Inserted after")));
```
#### `remove`
Removes the current node(s).
- **Example**:
```javascript
j.find(j.VariableDeclaration).remove();
```
#### `renameTo`
Renames the nodes in the collection to a new name.
- **Parameters**: `newName` (String)
- **Example**:
```javascript
root.find(j.Identifier, { name: "a" }).renameTo("x");
```
#### `toSource`
Converts the transformed AST back to source code.
- **Parameters**: `options` (Object)
- **Example**:
```javascript
const transformedSource = j.toSource({ quote: "single" });
```
## AST Grammar
jscodeshift provides 278 node types which are mapped to their corresponding node type in `ast-types`.
### Common Node Types
- **AnyTypeAnnotation**: A type annotation representing any type.
- **ArrayExpression**: Represents an array literal.
- **ArrayPattern**: A pattern that matches an array from a destructuring assignment.
- **ArrayTypeAnnotation**: A type annotation for arrays.
- **ArrowFunctionExpression**: An arrow function expression.
- **AssignmentExpression**: Represents an assignment expression.
- **AssignmentPattern**: A pattern that matches an assignment from a destructuring assignment.
- **AwaitExpression**: Represents an await expression.
- **BigIntLiteral**: A literal representing a big integer.
- **BinaryExpression**: Represents a binary expression.
- **BlockStatement**: Represents a block statement.
- **BooleanLiteral**: A literal representing a boolean value.
- **BreakStatement**: Represents a break statement.
- **CallExpression**: Represents a call expression.
- **CatchClause**: Represents a catch clause in a try statement.
- **ClassDeclaration**: Represents a class declaration.
- **ClassExpression**: Represents a class expression.
- **ClassMethod**: Represents a method of a class.
- **ClassProperty**: Represents a property of a class.
- **Comment**: Represents a comment in the code.
- **ConditionalExpression**: Represents a conditional expression (ternary).
- **ContinueStatement**: Represents a continue statement.
- **DebuggerStatement**: Represents a debugger statement.
- **Declaration**: Represents a declaration in the code.
- **DoWhileStatement**: Represents a do…while statement.
- **ExportAllDeclaration**: Represents an export all declaration.
- **ExportDeclaration**: Represents an export declaration.
- **ExportDefaultDeclaration**: Represents an export default declaration.
- **ExportNamedDeclaration**: Represents a named export declaration.
- **ExpressionStatement**: Represents an expression statement.
- **File**: Represents a file in the AST.
- **ForInStatement**: Represents a for-in statement.
- **ForOfStatement**: Represents a for-of statement.
- **ForStatement**: Represents a for statement.
- **FunctionDeclaration**: Represents a function declaration.
- **FunctionExpression**: Represents a function expression.
- **Identifier**: Represents an identifier.
- **IfStatement**: Represents an if statement.
- **ImportDeclaration**: Represents an import declaration.
- **ImportDefaultSpecifier**: Represents a default import specifier.
- **ImportNamespaceSpecifier**: Represents a namespace import specifier.
- **ImportSpecifier**: Represents an import specifier.
- **InterfaceDeclaration**: Represents an interface declaration.
- **JSXAttribute**: Represents an attribute in a JSX element.
- **JSXElement**: Represents a JSX element.
- **JSXExpressionContainer**: Represents an expression container in JSX.
- **JSXFragment**: Represents a JSX fragment.
- **JSXIdentifier**: Represents an identifier in JSX.
- **JSXText**: Represents text in JSX.
- **Literal**: Represents a literal value.
- **LogicalExpression**: Represents a logical expression.
- **MemberExpression**: Represents a member expression.
- **MethodDefinition**: Represents a method definition.
- **NewExpression**: Represents a new expression.
- **ObjectExpression**: Represents an object expression.
- **ObjectPattern**: Represents an object pattern for destructuring.
- **ObjectProperty**: Represents a property in an object.
- **Program**: Represents the entire program.
- **Property**: Represents a property in an object.
- **ReturnStatement**: Represents a return statement.
- **SpreadElement**: Represents a spread element in an array or function call.
- **StringLiteral**: Represents a string literal.
- **SwitchCase**: Represents a case in a switch statement.
- **SwitchStatement**: Represents a switch statement.
- **TemplateLiteral**: Represents a template literal.
- **ThisExpression**: Represents the `this` expression.
- **ThrowStatement**: Represents a throw statement.
- **TryStatement**: Represents a try statement.
- **TSAnyKeyword**: Represents the TypeScript `any` keyword.
- **TSArrayType**: Represents a TypeScript array type.
- **TSAsExpression**: Represents a TypeScript as-expression.
- **TSBooleanKeyword**: Represents the TypeScript `boolean` keyword.
- **TSDeclareFunction**: Represents a TypeScript function declaration.
- **TSEnumDeclaration**: Represents a TypeScript enum declaration.
- **TSInterfaceDeclaration**: Represents a TypeScript interface declaration.
- **TSNumberKeyword**: Represents the TypeScript `number` keyword.
- **TSStringKeyword**: Represents the TypeScript `string` keyword.
- **TSTypeAliasDeclaration**: Represents a TypeScript type alias declaration.
- **TSTypeAnnotation**: Represents a TypeScript type annotation.
- **TSTypeReference**: Represents a type reference in TypeScript.
- **TSUnionType**: Represents a union type in TypeScript.
- **UnaryExpression**: Represents a unary expression.
- **VariableDeclaration**: Represents a variable declaration.
- **VariableDeclarator**: Represents a variable declarator.
- **WhileStatement**: Represents a while statement.
For a complete list and detailed structure of each node, refer to the [AST Grammar documentation](https://jscodeshift.com/build/ast-grammar/).

View file

@ -0,0 +1,15 @@
{
"name": "@plane/codemods",
"version": "1.1.0",
"private": true,
"scripts": {
"test": "vitest run",
"function-declaration": "jscodeshift -t ./function-declaration.ts --extensions=tsx --parser=tsx ../../apps/*/app ../../apps/*/ce ../../apps/*/core ../../apps/*/ee ../../apps/*/helpers ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'"
},
"devDependencies": {
"@hypermod/utils": "^0.7.1",
"@types/jscodeshift": "^17.3.0",
"jscodeshift": "^17.3.0",
"vitest": "^4.0.8"
}
}

View file

@ -0,0 +1,465 @@
import { describe, it, expect } from "vitest";
import { applyTransform } from "@hypermod/utils";
import * as transformer from "../function-declaration";
describe("function-declaration", () => {
it("should convert arrow function components to function declarations", async () => {
const result = await applyTransform(
transformer,
`
import React from "react";
export const MyComponent: React.FC<{}> = () => {
return <div>Hello, world!</div>;
};
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import React from "react";
export function MyComponent() {
return <div>Hello, world!</div>;
}"
`);
});
it("should handle components with props", async () => {
const result = await applyTransform(
transformer,
`
import React from "react";
interface IMyComponentProps {
name: string;
}
export const MyComponent: React.FC<IMyComponentProps> = ({ name }) => {
return <div>Hello, {name}!</div>;
};
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import React from "react";
interface IMyComponentProps {
name: string;
}
export function MyComponent(
{
name
}: IMyComponentProps
) {
return <div>Hello, {name}!</div>;
}"
`);
});
it("should preserve default props", async () => {
const result = await applyTransform(
transformer,
`
import React from "react";
interface IMyComponentProps {
name?: string;
}
export const MyComponent: React.FC<IMyComponentProps> = ({ name = "world" }) => {
return <div>Hello, {name}!</div>;
};
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import React from "react";
interface IMyComponentProps {
name?: string;
}
export function MyComponent(
{
name = "world"
}: IMyComponentProps
) {
return <div>Hello, {name}!</div>;
}"
`);
});
it("should not transform non-component arrow functions", async () => {
const result = await applyTransform(
transformer,
`
const myFunction = () => {
return "hello";
};
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"const myFunction = () => {
return "hello";
};"
`);
});
it("should handle observer-wrapped components", async () => {
const result = await applyTransform(
transformer,
`
import { observer } from "mobx-react";
export const WorkspaceAnalyticsHeader = observer(() => {
return <div>Analytics</div>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import { observer } from "mobx-react";
export const WorkspaceAnalyticsHeader = observer(function WorkspaceAnalyticsHeader() {
return <div>Analytics</div>;
});"
`);
});
it("should handle inline arrow function components", async () => {
const result = await applyTransform(
transformer,
`
export const StarUsOnGitHubLink = () => {
return <a href="https://github.com">Star us</a>;
};
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"export function StarUsOnGitHubLink() {
return <a href="https://github.com">Star us</a>;
}"
`);
});
it("should handle React.FC type without generics", async () => {
const result = await applyTransform(
transformer,
`
import type { FC } from "react";
export const ProjectAppSidebar: FC = observer(() => {
return <div>Sidebar</div>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import type { FC } from "react";
export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
return <div>Sidebar</div>;
});"
`);
});
it("should handle inline JSX arrow function", async () => {
const result = await applyTransform(
transformer,
`
export const DateAlert = (props: TDateAlertProps) => <></>;
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"export function DateAlert(props: TDateAlertProps) {
return <></>;
}"
`);
});
it("should handle observer with generic type parameters", async () => {
const result = await applyTransform(
transformer,
`
import { observer } from "mobx-react";
export const InstanceProvider = observer<React.FC<React.PropsWithChildren>>((props) => {
const { children } = props;
return <>{children}</>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import { observer } from "mobx-react";
export const InstanceProvider = observer(function InstanceProvider(props: React.PropsWithChildren) {
const { children } = props;
return <>{children}</>;
});"
`);
});
it("should not add double semicolons after use client directive", async () => {
const result = await applyTransform(
transformer,
`
"use client";
import { observer } from "mobx-react";
export const MyComponent = observer(() => {
return <div>Hello</div>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
""use client";
import { observer } from "mobx-react";
export const MyComponent = observer(function MyComponent() {
return <div>Hello</div>;
});"
`);
});
it("should preserve generic type parameters in wrapper functions", async () => {
const result = await applyTransform(
transformer,
`
import React from "react";
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
return <div>Chart</div>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import React from "react";
export const ScatterChart = React.memo(
function ScatterChart<K extends string, T extends string>(props: TScatterChartProps<K, T>) {
return <div>Chart</div>;
}
);"
`);
});
it("should preserve generic type parameters on React.forwardRef", async () => {
const result = await applyTransform(
transformer,
`
import React from "react";
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <button ref={ref}>Click me</button>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import React from "react";
const Button = React.forwardRef(
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
return <button ref={ref}>Click me</button>;
}
);"
`);
});
it("should prefix unused props parameter with underscore", async () => {
const result = await applyTransform(
transformer,
`
import type { TCallbackMentionComponentProps } from "@plane/editor";
export const EditorAdditionalMentionsRoot: React.FC<TCallbackMentionComponentProps> = () => null;
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import type { TCallbackMentionComponentProps } from "@plane/editor";
export function EditorAdditionalMentionsRoot(_props: TCallbackMentionComponentProps) {
return null;
}"
`);
});
it("should add Record<string, unknown> type for React.forwardRef with only element type", async () => {
const result = await applyTransform(
transformer,
`
import { forwardRef } from "react";
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref}>Content</div>
));
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"import { forwardRef } from "react";
const ListLoaderItemRow = forwardRef(
function ListLoaderItemRow(props: Record<string, unknown>, ref: React.ForwardedRef<HTMLDivElement>) {
return (<div ref={ref}>Content</div>);
}
);"
`);
});
it("should preserve comments in function body", async () => {
const result = await applyTransform(
transformer,
`
export const PreloadResources = () => (
// usePreloadResources();
null
);
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"export function PreloadResources() {
return (
// usePreloadResources();
(null)
);
}"
`);
});
it("should preserve leading comments before export declaration", async () => {
const result = await applyTransform(
transformer,
`
"use client";
// TODO: Check if we need this
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload
// export const usePreloadResources = () => {
// useEffect(() => {
// const preloadItem = (url: string) => {
// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" });
// };
//
// const urls = [
// \`\${process.env.VITE_API_BASE_URL}/api/instances/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/profile/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/settings/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=\${Date.now()}\`,
// ];
//
// urls.forEach((url) => preloadItem(url));
// }, []);
// };
export const PreloadResources = () =>
// usePreloadResources();
null;
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
""use client";
// TODO: Check if we need this
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload
// export const usePreloadResources = () => {
// useEffect(() => {
// const preloadItem = (url: string) => {
// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" });
// };
//
// const urls = [
// \`\${process.env.VITE_API_BASE_URL}/api/instances/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/profile/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/settings/\`,
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=\${Date.now()}\`,
// ];
//
// urls.forEach((url) => preloadItem(url));
// }, []);
// };
export function PreloadResources() {
return (
// usePreloadResources();
null
);
}"
`);
});
it("should preserve leading comments before wrapped export declaration", async () => {
const result = await applyTransform(
transformer,
`
// This is a wrapped component
// It uses observer for reactivity
export const MyObserverComponent = observer(() => {
return <div>Observer component</div>;
});
`,
{ parser: "tsx" },
);
expect(result).toMatchInlineSnapshot(`
"// This is a wrapped component
// It uses observer for reactivity
export const MyObserverComponent = observer(function MyObserverComponent() {
return <div>Observer component</div>;
});"
`);
});
it("should preserve trailing comments on exported variable declaration", async () => {
const result = await applyTransform(
transformer,
`
export const Foo = () => <div />; // trailing comment
`,
{ parser: "tsx" },
);
expect(result).toContain("// trailing comment");
});
it("should preserve leading comments on exported variable declaration inside export", async () => {
const result = await applyTransform(
transformer,
`
export /* leading comment */ const Foo = () => <div />;
`,
{ parser: "tsx" },
);
expect(result).toContain("/* leading comment */");
});
});

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});