diff --git a/README.md b/README.md index 5fbd4c6..1ee6fa0 100644 --- a/README.md +++ b/README.md @@ -288,3 +288,7 @@ const Foo = () => ( ) ``` + +# `convertSimpleClassComponentsToFunctions` + +Converts `React.Component` subclasses with only a `render` method (no lifecycle methods, constructors, or class properties other than `propTypes`, `contextTypes`, `defaultProps`, and no `state` type parameter) into functional components. diff --git a/package.json b/package.json index c4261d4..836edcd 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ }, "dependencies": { "@babel/runtime": "^7.1.5", + "jscodeshift-find-imports": "^2.0.0", "jscodeshift-paths-in-range": "^1.0.0" }, "renovate": { diff --git a/src/convertSimpleClassComponentsToFunctions.ts b/src/convertSimpleClassComponentsToFunctions.ts new file mode 100644 index 0000000..fe2e118 --- /dev/null +++ b/src/convertSimpleClassComponentsToFunctions.ts @@ -0,0 +1,128 @@ +import { + ASTPath, + FileInfo, + API, + ClassDeclaration, + ClassMethod, + ClassProperty, +} from 'jscodeshift' +import findImports from 'jscodeshift-find-imports' + +module.exports = function convertSimpleClassComponentsToFunctions( + fileInfo: FileInfo, + api: API +): string | null | undefined | void { + const j = api.jscodeshift + + const root = j(fileInfo.source) + const { Component } = findImports( + root, + j.template.statement`import { Component } from 'react'` + ) + root + .find(j.ClassDeclaration, { + superClass: + Component.type === 'Identifier' + ? { type: 'Identifier', name: Component.name } + : Component.type === 'MemberExpression' + ? { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: (Component.object as any).name, + }, + property: { + type: 'Identifier', + name: (Component.property as any).name, + }, + } + : null, + }) + .forEach((path: ASTPath) => { + const { id, superTypeParameters, body } = path.node + const superTypeParams = superTypeParameters?.params || [] + const renderMethodColl = j([path]).find(j.ClassMethod, { + key: { type: 'Identifier', name: 'render' }, + }) + const renderMethod: ClassMethod | void = renderMethodColl.nodes()[0] + const contextTypesColl = j([path]).find(j.ClassProperty, { + static: true, + key: { type: 'Identifier', name: 'contextTypes' }, + }) + const contextTypes: ClassProperty | void = contextTypesColl.nodes()[0] + const propTypesColl = j([path]).find(j.ClassProperty, { + static: true, + key: { type: 'Identifier', name: 'propTypes' }, + }) + const propTypes: ClassProperty | void = propTypesColl.nodes()[0] + const defaultPropsColl = j([path]).find(j.ClassProperty, { + static: true, + key: { type: 'Identifier', name: 'defaultProps' }, + }) + const defaultProps: ClassProperty | void = defaultPropsColl.nodes()[0] + + if ( + !id || + !renderMethod || + superTypeParams.length > 1 || + body.body.find( + node => + node !== renderMethod && + node !== propTypes && + node !== contextTypes && + node !== defaultProps + ) + ) + return + + j([path]) + .find(j.MemberExpression, { + object: { type: 'ThisExpression' }, + property: { type: 'Identifier', name: 'props' }, + }) + .replaceWith(() => j.identifier('props')) + j([path]) + .find(j.MemberExpression, { + object: { type: 'ThisExpression' }, + property: { type: 'Identifier', name: 'context' }, + }) + .replaceWith(() => j.identifier('context')) + const propsParam = j.identifier('props') + if (superTypeParams[0]) { + propsParam.typeAnnotation = + (superTypeParameters as any).type === 'TSTypeParameterInstantiation' + ? j.tsTypeAnnotation(superTypeParams[0] as any) + : j.typeAnnotation(superTypeParams[0] as any) + } + const params = [propsParam] + if (contextTypes) { + params.push(j.identifier('context')) + } + const func = j.functionDeclaration(id, params, renderMethod.body) + func.returnType = renderMethod.returnType + const [replaced] = path.replace(func) + if (contextTypes) { + j([replaced]) + .closest(j.Statement) + .insertAfter( + j.template.statement`${id}.contextTypes = ${contextTypes.value}\n` + ) + } + if (defaultProps) { + j([replaced]) + .closest(j.Statement) + .insertAfter( + j.template.statement`${id}.defaultProps = ${defaultProps.value}\n` + ) + } + if (propTypes) { + j([replaced]) + .closest(j.Statement) + .insertAfter( + j.template.statement`${id}.propTypes = ${propTypes.value}\n` + ) + } + }) + + return root.toSource() +} diff --git a/test/convertSimpleClassComponentsToFunctions/basic.ts b/test/convertSimpleClassComponentsToFunctions/basic.ts new file mode 100644 index 0000000..e8279da --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/basic.ts @@ -0,0 +1,38 @@ +export const file = 'test.js' +export const parser = 'babylon' + +export const options = {} + +export const input = ` +import * as React from 'react' +export default class Foo extends React.Component { + static propTypes = { + title: PropTypes.string.isRequired, + } + static contextTypes = { + temp: PropTypes.string, + } + static defaultProps = { + title: 'Title', + } + render() { + return
{this.props.title} {this.context.temp}
+ } +} +` + +export const expected = ` +import * as React from 'react' +export default function Foo(props, context) { + return
{props.title} {context.temp}
+} +Foo.propTypes = { + title: PropTypes.string.isRequired, +} +Foo.defaultProps = { + title: 'Title', +} +Foo.contextTypes = { + temp: PropTypes.string, +} +` diff --git a/test/convertSimpleClassComponentsToFunctions/cantConvertBasic.ts b/test/convertSimpleClassComponentsToFunctions/cantConvertBasic.ts new file mode 100644 index 0000000..71924c8 --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/cantConvertBasic.ts @@ -0,0 +1,16 @@ +export const file = 'test.js' +export const parser = 'babylon' + +export const options = {} + +export const input = ` +import * as React from 'react' +class Bar extends React.Component { + componentDidUpdate(prevProps) {} + render() { + return
{this.props.title}
+ } +} +` + +export const expected = input diff --git a/test/convertSimpleClassComponentsToFunctions/cantConvertFlow.ts b/test/convertSimpleClassComponentsToFunctions/cantConvertFlow.ts new file mode 100644 index 0000000..061f22e --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/cantConvertFlow.ts @@ -0,0 +1,21 @@ +export const file = 'test.js' +export const parser = 'babylon' + +export const options = {} + +export const input = ` +import * as React from 'react' +class Foo extends React.Component { + render(): React.Node | null { + return
{this.props.title}
+ } +} +class Bar extends React.Component { + componentDidUpdate(prevProps: Props) {} + render(): React.Node | null { + return
{this.props.title}
+ } +} +` + +export const expected = input diff --git a/test/convertSimpleClassComponentsToFunctions/cantConvertTypescript.ts b/test/convertSimpleClassComponentsToFunctions/cantConvertTypescript.ts new file mode 100644 index 0000000..3ac541f --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/cantConvertTypescript.ts @@ -0,0 +1,21 @@ +export const file = 'test.js' +export const parser = 'babylon' + +export const options = {} + +export const input = ` +import * as React from 'react' +class Foo extends React.Component { + render(): React.ReactNode | null { + return
{this.props.title}
+ } +} +class Bar extends React.Component { + componentDidUpdate(prevProps: Props) {} + render(): React.ReactNode | null { + return
{this.props.title}
+ } +} +` + +export const expected = input diff --git a/test/convertSimpleClassComponentsToFunctions/flow.ts b/test/convertSimpleClassComponentsToFunctions/flow.ts new file mode 100644 index 0000000..4e75661 --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/flow.ts @@ -0,0 +1,26 @@ +export const file = 'test.js' +export const parser = 'babylon' + +export const options = {} + +export const input = ` +import * as React from 'react' +export default class Foo extends React.Component { + static propTypes = { + title: PropTypes.string.isRequired, + } + render(): React.Node | null { + return
{this.props.title}
+ } +} +` + +export const expected = ` +import * as React from 'react' +export default function Foo(props: Props): React.Node | null { + return
{props.title}
+} +Foo.propTypes = { + title: PropTypes.string.isRequired, +} +` diff --git a/test/convertSimpleClassComponentsToFunctions/typescript.ts b/test/convertSimpleClassComponentsToFunctions/typescript.ts new file mode 100644 index 0000000..4656f6f --- /dev/null +++ b/test/convertSimpleClassComponentsToFunctions/typescript.ts @@ -0,0 +1,20 @@ +export const file = 'test.tsx' +export const parser = 'tsx' + +export const options = {} + +export const input = ` +import * as React from 'react' +export default class Foo extends React.Component { + render(): React.ReactNode | null { + return
{this.props.title}
+ } +} +` + +export const expected = ` +import * as React from 'react' +export default function Foo(props: Props): React.ReactNode | null { + return
{props.title}
+} +` diff --git a/yarn.lock b/yarn.lock index 3529b9a..c2177c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1635,6 +1635,11 @@ assign-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= +ast-types@0.11.7: + version "0.11.7" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.11.7.tgz#f318bf44e339db6a320be0009ded64ec1471f46c" + integrity sha512-2mP3TwtkY/aTv5X3ZsMpNAbOnyoC/aMJwJSoaELPkHId0nSQgFcnU4dRW3isxiz7+zBexk0ym3WNVjMiQBnJSw== + ast-types@0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.12.1.tgz#55d3737a8a68e1ccde131067005ce7ee3dd42b99" @@ -4873,6 +4878,13 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= +jscodeshift-find-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jscodeshift-find-imports/-/jscodeshift-find-imports-2.0.0.tgz#d9bddc3eddbc927017004664fefd5bcf49e432de" + integrity sha512-ZYmZJ17pU/CGDaB6PWo4j7NVazVuwBDBXUrGnF57YcGvSA1toTToE/YitdBbCwariM4ky+gPWbM383rAmlbJQw== + dependencies: + jscodeshift "^0.6.4" + jscodeshift-paths-in-range@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/jscodeshift-paths-in-range/-/jscodeshift-paths-in-range-1.1.0.tgz#accc511afd807094f274a497f136b6d338efda8d" @@ -4880,6 +4892,30 @@ jscodeshift-paths-in-range@^1.0.0: dependencies: "@babel/runtime" "^7.1.5" +jscodeshift@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-0.6.4.tgz#e19ab86214edac86a75c4557fc88b3937d558a8e" + integrity sha512-+NF/tlNbc2WEhXUuc4WEJLsJumF84tnaMUZW2hyJw3jThKKRvsPX4sPJVgO1lPE28z0gNL+gwniLG9d8mYvQCQ== + dependencies: + "@babel/core" "^7.1.6" + "@babel/parser" "^7.1.6" + "@babel/plugin-proposal-class-properties" "^7.1.0" + "@babel/plugin-proposal-object-rest-spread" "^7.0.0" + "@babel/preset-env" "^7.1.6" + "@babel/preset-flow" "^7.0.0" + "@babel/preset-typescript" "^7.1.0" + "@babel/register" "^7.0.0" + babel-core "^7.0.0-bridge.0" + colors "^1.1.2" + flow-parser "0.*" + graceful-fs "^4.1.11" + micromatch "^3.1.10" + neo-async "^2.5.0" + node-dir "^0.1.17" + recast "^0.16.1" + temp "^0.8.1" + write-file-atomic "^2.3.0" + jscodeshift@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-0.7.0.tgz#4eee7506fd4fdacbd80340287d61575af991fdab" @@ -7255,6 +7291,16 @@ recast@0.17.2: private "~0.1.5" source-map "~0.6.1" +recast@^0.16.1: + version "0.16.2" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.16.2.tgz#3796ebad5fe49ed85473b479cd6df554ad725dc2" + integrity sha512-O/7qXi51DPjRVdbrpNzoBQH5dnAPQNbfoOFyRiUwreTMJfIHYOEBzwuH+c0+/BTSJ3CQyKs6ILSWXhESH6Op3A== + dependencies: + ast-types "0.11.7" + esprima "~4.0.0" + private "~0.1.5" + source-map "~0.6.1" + recast@^0.18.1: version "0.18.5" resolved "https://registry.yarnpkg.com/recast/-/recast-0.18.5.tgz#9d5adbc07983a3c8145f3034812374a493e0fe4d"