diff --git a/.changeset/purple-panthers-attack.md b/.changeset/purple-panthers-attack.md new file mode 100644 index 0000000000..81b404b2ff --- /dev/null +++ b/.changeset/purple-panthers-attack.md @@ -0,0 +1,40 @@ +--- +'@commercetools-frontend/codemod': minor +--- + +Introduces a new codemod which helps migrating away from React's `defaultProps` to `prop` destructuring. + +This is how the change looks like: + +```ts +// BEFORE +type TMyComponentProps = { + message: string; + size: string; +} + +function MyComponent(props: TMyComponentProps) { + ... +} + +MyComponent.defaultProps = { + size: 'big' +} + + +// AFTER +type TMyComponentProps = { + message: string; + size?: string; // <--- Note this property is now defined as optional +} + +function MyComponent({ size = 'big', ...props }: TMyComponentProps) { + ... +} +``` + +And here is how the new codemod can be run: + +``` +$ npx @commercetools-frontend/codemod@latest react-default-props-migration 'src/**/*.{jsx,tsx}' +``` diff --git a/packages/codemod/README.md b/packages/codemod/README.md index 65be1b6cfb..981a1bbb94 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -49,3 +49,41 @@ Remove code related to the old design when using the `useTheme` hook, for exampl ``` $ npx @commercetools-frontend/codemod@latest redesign-cleanup 'src/**/*.{jsx,tsx}' ``` + +### `react-default-props-migration` + +Migrates the way React Components `defaultProps` to use JavaScript default parameters instead. This is needed for React v18 or later. +Example: + +```jsx +// BEFORE +function MyComponent(props) { + return ( + + ); +} +MyComponent.defaultProps = { + prop1: 'My default value', +}; + +// AFTER +function MyComponent({ prop1: 'My default value', ...props }) { + return ( + + ); +} +``` + +You can run this codemod by using the following command: + +``` +$ npx @commercetools-frontend/codemod@latest react-default-props-migration 'src/**/*.{jsx,tsx}' +``` diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts index 41de44e0ec..83edceaa42 100755 --- a/packages/codemod/src/cli.ts +++ b/packages/codemod/src/cli.ts @@ -31,6 +31,11 @@ const transforms: { name: TCliTransformName; description: string }[] = [ description: 'Remove code related to the old design when using the "useTheme" hook, for example the usage of "themedValue".', }, + { + name: 'react-default-props-migration', + description: + 'Migrate React components using defaultProps as a component property to a destructured object param.', + }, ]; const executeCodemod = async ( @@ -38,7 +43,18 @@ const executeCodemod = async ( globPattern: string, globalOptions: TCliGlobalOptions ) => { - const files = glob.sync(globPattern); + const absoluteGlobPattern = path.resolve(globPattern); + const files = glob.sync( + path.join(absoluteGlobPattern, '**/*.{ts,tsx,js,jsx}'), + { + ignore: [ + '**/node_modules/**', + '**/public/**', + '**/dist/**', + '**/build/**', + ], + } + ); const runJscodeshift = async ( transformPath: string, @@ -49,6 +65,7 @@ const executeCodemod = async ( }; switch (transform) { case 'redesign-cleanup': + case 'react-default-props-migration': case 'remove-deprecated-modal-level-props': case 'rename-js-to-jsx': case 'rename-mod-css-to-module-css': { @@ -56,12 +73,6 @@ const executeCodemod = async ( await runJscodeshift(transformPath, files, { extensions: 'tsx,ts,jsx,js', - ignorePattern: [ - '**/node_modules/**', - '**/public/**', - '**/dist/**', - '**/build/**', - ], parser: 'tsx', verbose: 0, dry: globalOptions.dryRun, diff --git a/packages/codemod/src/transforms/react-default-props-migration.ts b/packages/codemod/src/transforms/react-default-props-migration.ts new file mode 100644 index 0000000000..cac3c625a7 --- /dev/null +++ b/packages/codemod/src/transforms/react-default-props-migration.ts @@ -0,0 +1,525 @@ +import { + API, + AssignmentExpression, + ASTPath, + CallExpression, + Collection, + ExpressionStatement, + FileInfo, + JSCodeshift, + MemberExpression, + ObjectExpression, + PropertyPattern, + TSTypeAliasDeclaration, +} from 'jscodeshift'; +import prettier from 'prettier'; +import { TRunnerOptions } from '../types'; + +type TDefaultPropsMap = Record; + +// When adjusting the component body where the previous props object was used +// we don't need to adjust the function calls that are listed here +const IGNORED_FUNCTIONS_ON_BODY_ADJUSTMENTS = [ + 'filterAriaAttributes', + 'filterDataAttributes', +]; + +/* + Given the component function parameter description, we extract the + Typescript type name (if any). + + Somethibng like this: + (props: MyComponentProps) -> MyComponentProps +*/ +function resolvePropsTypescriptType( + propsParam: PropertyPattern['pattern'] +): string | undefined { + if ( + propsParam.type === 'ObjectPattern' && + propsParam.typeAnnotation && + propsParam.typeAnnotation.type === 'TSTypeAnnotation' && + propsParam.typeAnnotation.typeAnnotation.type === 'TSTypeReference' && + propsParam.typeAnnotation.typeAnnotation.typeName.type === 'Identifier' + ) { + return propsParam.typeAnnotation.typeAnnotation.typeName.name; + } + return undefined; +} + +/* + This helper takes care of replacing the defaultProps usage in the component body + Previously the component code was relying on the props object to access the default values + but now the default props are destructured in the function signature + Example: + ``` + // BEFORE + const MyComponent = (props) => { + return
{props.prop1}
; + } + // AFTER + const MyComponent = ({ prop1 }) => { + return
{prop1}
; + } + ``` +*/ +function replacePropsUsage({ + j, + defaultPropsKeys, + scope, +}: { + j: JSCodeshift; + defaultPropsKeys: string[]; + scope: Collection; +}) { + /* + Next code block replaces destructured props usage in the component body. + ``` + // BEFORE + const MyComponent = (props) => { + return
{props.prop1}
; + } + } + + // AFTER + const MyComponent = ({ prop1, ...props }) => { + return
{prop1}
; + } + ``` + */ + scope + .find(j.MemberExpression, { + object: { type: 'Identifier', name: 'props' }, + }) + .forEach((memberPath: ASTPath) => { + const property = memberPath.node.property; + // Add type guard for Identifier + if ( + property.type === 'Identifier' && + defaultPropsKeys.includes(property.name) + ) { + j(memberPath).replaceWith(j.identifier(property.name)); + } + }); + + /* + Next code block replaces props usage in the component body where + props is passed as an argument to a function. + ``` + // BEFORE + const MyComponent = (props) => { + return
{getStyles(props)}
; + } + + // AFTER + const MyComponent = ({ prop1, ...props }) => { + return
{getStyles({ prop1, ...props })}
; + } + ``` + */ + scope + .find(j.CallExpression, { + // There are some functions that don't need to be adjusted + // (example: filterAriaAttributes, filterDataAttributes) + callee: { + type: 'Identifier', + name: (name: string) => + !IGNORED_FUNCTIONS_ON_BODY_ADJUSTMENTS.includes(name), + }, + arguments: [{ type: 'Identifier', name: 'props' }], + }) + .forEach((callPath: ASTPath) => { + // Create a destructured object + const properties = [ + ...defaultPropsKeys.map((key) => { + const id = j.identifier(key); + const newProp = j.property('init', id, id); + newProp.shorthand = true; + return newProp; + }), + j.spreadElement(j.identifier('props')), + ]; + + const objectExpression = j.objectExpression(properties); + + // Replace the 'props' argument with the destructured object + callPath.node.arguments[0] = objectExpression; + }); + + /* + Next code block replaces props spread in JSX elements + ``` + // BEFORE + const MyComponent = (props) => { + return + } + + // AFTER + const MyComponent = ({ prop1, ...props }) => { + return + } + ``` + */ + scope + .find(j.JSXSpreadAttribute, { + argument: { type: 'Identifier', name: 'props' }, + }) + .forEach((path) => { + const attributes = defaultPropsKeys.map((key) => + j.jsxAttribute( + j.jsxIdentifier(key), + j.jsxExpressionContainer(j.identifier(key)) + ) + ); + + // Replace the spread with individual attributes + j(path).replaceWith([ + ...attributes, + j.jsxSpreadAttribute(j.identifier('props')), + ]); + }); +} + +/* + We need to make sure the component type definition is updated to reflect the + props that are now optional. + Example: + ``` + // BEFORE + type MyComponentProps = { + prop1: string; + prop2: string; + prop3: string; + } + function MyComponent(props: MyComponentProps) { ... } + MyComponent.defaultProps = { + prop1: 'default value', + } + + // AFTER + type MyComponentProps = { + prop1?: string; + prop2: string; + prop3: string; + } + function MyComponent({ prop1, ...props }: MyComponentProps) { ... } + ``` +*/ +function updateComponentTypes({ + j, + root, + typeName, + destructuredKeys, +}: { + j: JSCodeshift; + root: Collection; + typeName: string; + destructuredKeys: string[]; +}) { + // Find the type definition of the component props + root + .find(j.TSTypeAliasDeclaration) + .forEach((typePath: ASTPath) => { + if (typePath.node.id.name === typeName) { + const typeAnnotation = typePath.node.typeAnnotation; + + if (typeAnnotation.type === 'TSTypeLiteral') { + typeAnnotation.members.forEach((member) => { + if ( + member.type === 'TSPropertySignature' && + member.key.type === 'Identifier' && + destructuredKeys.includes(member.key.name) + ) { + member.optional = true; + } + }); + } + } + }); +} + +/* + This helper transforms the component function signature to use a destructured object + as first parameter, so we can append the default props to it. + Example: + ``` + // BEFORE + const MyComponent = (props) => { ... } + + // AFTER + const MyComponent = ({ prop1, ...props }) => { ... } + ``` +*/ +function transformComponentFunctionSignature({ + functionPropsParam, + defaultPropsMap, + componentName, + j, +}: { + functionPropsParam: PropertyPattern['pattern']; + defaultPropsMap: TDefaultPropsMap; + componentName: string; + j: JSCodeshift; +}): PropertyPattern['pattern'] { + let refactoredParameter: PropertyPattern['pattern']; + const defaultPropsKeys = Object.keys(defaultPropsMap); + switch (functionPropsParam.type) { + // In this case, the component already has a destructured object as first parameter + // so we need to append the defaultProps to it + // const MyComnponent = ({ prop1, ...props }) => { ... } + case 'ObjectPattern': + refactoredParameter = functionPropsParam; + refactoredParameter.properties = [ + ...transformDefaultPropsToAST(defaultPropsMap, j), + // If the destructured object already had one of the default props, filter it out + ...functionPropsParam.properties.filter((prop) => { + return ( + prop.type !== 'ObjectProperty' || + prop.key.type !== 'Identifier' || + !defaultPropsKeys.includes(prop.key.name) + ); + }), + ]; + break; + // In this case, the component has a simple parameter as first parameter + // so we need to refactor it to a destructured object + // const MyComnponent = (props) => { ... } + case 'Identifier': + refactoredParameter = j.objectPattern([ + ...transformDefaultPropsToAST(defaultPropsMap, j), + j.spreadProperty(j.identifier('props')), + ]); + + // Make sure the refactored parameter has the same type annotation + // as the original one + refactoredParameter.typeAnnotation = functionPropsParam.typeAnnotation; + break; + default: + console.warn( + `[WARNING]: Could not parse component function first parameter "${componentName}"` + ); + } + + return refactoredParameter!; +} + +/* + This helper extracts the default props keys/values from the defaultProps object node +*/ +function extractDefaultPropsFromNode( + defaultPropsNode: ObjectExpression +): TDefaultPropsMap { + return defaultPropsNode.properties.reduce((acc, prop) => { + if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') { + return { + ...acc, + [prop.key.name as string]: + prop.value as ExpressionStatement['expression'], + }; + } + return acc; + }, {} as TDefaultPropsMap); +} + +/* + This helper transforms the default props keys/values to an AST representation + so we can easily append them to the component function signature. + ``` +*/ +function transformDefaultPropsToAST( + defaultPropsMap: TDefaultPropsMap, + j: JSCodeshift +) { + return Object.entries(defaultPropsMap).map(([key, value]) => { + const propNode = j.objectProperty( + j.identifier(key), + j.assignmentPattern(j.identifier(key), value) + ); + propNode.shorthand = true; + return propNode; + }); +} + +async function reactDefaultPropsMigration( + file: FileInfo, + api: API, + options: TRunnerOptions +) { + const j = api.jscodeshift; + const root = j(file.source, { comment: false }); + const originalSource = root.toSource(); + + console.log('Processing file:', file.path); + + // 1. Search for "defaultProps" definitions + root + .find(j.AssignmentExpression, { + left: { + type: 'MemberExpression', + property: { name: 'defaultProps' }, + }, + }) + .forEach((path: ASTPath) => { + // Types validation to please Typescript + if ( + path.node.left.type === 'MemberExpression' && + path.node.left.object.type === 'Identifier' + ) { + // The node path looks like this: + // defaultProps: MyComponent.defaultProps = defaultProps; + const componentName = path.node.left.object.name; + const defaultPropsNode = path.node.right; + let componentPropsTypescriptType: string | undefined; // Only TypeScript files have type annotations + let defaultPropsMap: TDefaultPropsMap = {}; + let functionScope: Collection; + + // 2. We now extract the default props values + // Default props can be defined inline or as a reference to another object + // INLINE -- MyComponent.defaultProps: { prop1: 'value1', prop2: 'value2' } + // REFERENCE -- MyComponent.defaultProps: defaultProps + if (defaultPropsNode.type === 'Identifier') { + // REFERENCE -- MyComponent.defaultProps: defaultProps + // A) Look for the identifier declaration + const defaultPropsDeclarations = root.find(j.VariableDeclarator, { + id: { type: 'Identifier', name: defaultPropsNode.name }, + }); + + if (defaultPropsDeclarations.size() === 1) { + // B) Extract default props keys/values + defaultPropsMap = extractDefaultPropsFromNode( + defaultPropsDeclarations.nodes()[0].init as ObjectExpression + ); + // C) Remove the identifier declaration + defaultPropsDeclarations.remove(); + } else { + console.warn( + `[WARNING]: Could not find defaultProps declaration for "${componentName}"` + ); + } + } else if (defaultPropsNode.type === 'ObjectExpression') { + // INLINE -- MyComponent.defaultProps: { prop1: 'value1', prop2: 'value2' } + // Extract default props keys/values + defaultPropsMap = extractDefaultPropsFromNode(defaultPropsNode); + } else { + console.warn( + `[WARNING]: Do not know how to process default props for component "${componentName}": ${j( + path + ).toSource()}` + ); + return; + } + + // 3. Next we update the component function signature + // We first look for classic function declarations + // function MyComnponent(props) { ... } + const functionComponentDeclaration = root.find(j.FunctionDeclaration, { + id: { name: componentName }, + }); + if (functionComponentDeclaration.length === 1) { + functionComponentDeclaration.nodes()[0].params[0] = + transformComponentFunctionSignature({ + functionPropsParam: + functionComponentDeclaration.nodes()[0].params[0], + defaultPropsMap, + componentName, + j, + }); + + // Extract the component props TS type name + componentPropsTypescriptType = resolvePropsTypescriptType( + functionComponentDeclaration.nodes()[0].params[0] + ); + + // Get the function body scope so we only do the replacement + // within the component function we're currently processing + functionScope = j(functionComponentDeclaration.nodes()[0].body); + } else { + // If we don't find a function declaration, we look for arrow function declarations + // const MyComnponent = (props) => { ... } + // const MyComnponent = ({ prop1, ...props }) => { ... } + const variableComponentDeclaration = root.find( + j.VariableDeclaration, + { + declarations: [ + { + id: { name: componentName }, + }, + ], + } + ); + if (variableComponentDeclaration.length === 1) { + const functionFirstParamNode = + variableComponentDeclaration.nodes()[0].declarations[0]; + + if ( + functionFirstParamNode.type === 'VariableDeclarator' && + functionFirstParamNode.init?.type === 'ArrowFunctionExpression' + ) { + functionFirstParamNode.init.params[0] = + transformComponentFunctionSignature({ + functionPropsParam: functionFirstParamNode.init.params[0], + defaultPropsMap, + componentName, + j, + }); + + // Extract the component props TS type name + componentPropsTypescriptType = resolvePropsTypescriptType( + functionFirstParamNode.init.params[0] + ); + + // Get the function body scope so we only do the replacement + // within the component function we're currently processing + functionScope = j(functionFirstParamNode.init.body); + } else { + console.warn( + `[WARNING]: Could parse component function first parameter "${componentName}"` + ); + return; + } + } else { + console.warn( + `[WARNING]: Could not find component declaration for "${componentName}" (class component are ignored)` + ); + return; + } + } + + // 4. Refactor the usages of the default props in the body of the component + replacePropsUsage({ + j, + defaultPropsKeys: Object.keys(defaultPropsMap), + scope: functionScope!, + }); + + // 5. Update the component TS type definition so we make sure the default props are optional + // (not needed for Javascript files) + if (componentPropsTypescriptType) { + updateComponentTypes({ + j, + root, + typeName: componentPropsTypescriptType, + destructuredKeys: Object.keys(defaultPropsMap), + }); + } + + // 6. Remove the defaultProps assignment from the component + j(path).remove(); + } + }); + + // Do not return anything if no changes were applied + // so we don't rewrite the file + if (originalSource === root.toSource()) { + return null; + } + + if (!options.dry) { + // Format output code with prettier + const prettierConfig = await prettier.resolveConfig(file.path); + return prettier.format(root.toSource(), prettierConfig!); + } else { + return null; + } +} + +export default reactDefaultPropsMigration; diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts index ff1b532b37..b3b08fdfc3 100644 --- a/packages/codemod/src/types.ts +++ b/packages/codemod/src/types.ts @@ -16,4 +16,5 @@ export type TCliTransformName = | 'remove-deprecated-modal-level-props' | 'rename-js-to-jsx' | 'rename-mod-css-to-module-css' - | 'redesign-cleanup'; + | 'redesign-cleanup' + | 'react-default-props-migration';