Skip to content

Commit

Permalink
feat: add codemods to migrate from react-loadable (#463)
Browse files Browse the repository at this point in the history
  • Loading branch information
jackyef authored and gregberge committed Jan 9, 2020
1 parent 338bf55 commit a82d5ad
Show file tree
Hide file tree
Showing 16 changed files with 672 additions and 13 deletions.
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dist/
lib/
build/
/website/.cache/
/website/public/
/website/public/
__testfixtures__/
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ CHANGELOG.md
package.json
lerna.json
/website/.cache/
/website/public/
/website/public/
__testfixtures__/
11 changes: 11 additions & 0 deletions packages/codemod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# @loadable/codemod

This package is a collection of codemod that can be used to help making big changes easier to a project, for example: migrating from `react-loadable` to `@loadable/component`

## Notes about `react-loadable-to-loadable-component` transform
`react-loadable-to-loadable-component` transform will help codemod all of your `Loadable()` declaration to `loadable()` with mostly equivalent params, barring some behavior that do not exist in `@loadable/component` such as `Loadable.Map()`, `timeout`, `delay`, etc.

After running the codemod, you will still need to update some of your code manually, namely:
1. Using `loadableReady` to hydrate your app on the client side.
2. Updating your webpack configuration to use `@loadable`
3. Updating your server side rendering code to use `ChunkExtractor`
74 changes: 74 additions & 0 deletions packages/codemod/bin/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node

/* eslint-disable no-console */
const yargs = require('yargs')
const execa = require('execa')
const path = require('path')
const fs = require('fs')
const chalk = require('chalk')
const CodemodError = require('./utils/CodemodError')

const jscodeshiftExecutable = require.resolve('.bin/jscodeshift')
const transformsDir = path.resolve(__dirname, '../transforms')

const { argv } = yargs

try {
const selectedCodemod = argv._[0]
const directoryToApplyTo = argv._[1]

if (!selectedCodemod || !directoryToApplyTo) {
throw new CodemodError({
type: 'Invalid params',
})
}

const availableTransforms = fs
.readdirSync(transformsDir)
.filter(v => v !== '__tests__' && v !== '__testfixtures__')
.map(v => v.replace('.js', ''))

if (!availableTransforms.some(t => t === selectedCodemod)) {
throw new CodemodError({
type: 'Unrecognised transform',
payload: selectedCodemod,
})
}

const result = execa.commandSync(
`${jscodeshiftExecutable} --parser babylon -t ${transformsDir}/${selectedCodemod}.js ${directoryToApplyTo}`,
{
stdio: 'inherit',
stripEof: false,
},
)

if (result.error) {
throw result.error
}
} catch (err) {
if (err.type === 'Invalid params') {
console.error(chalk.red('Invalid params passed!'))
console.error(
chalk.red(
'loadable-codemod requires 2 params to be passed, the name of the codemod, and a directory to apply the codemod to.',
),
)
console.error(
chalk.red(
'Example: npx loadable-codemod react-loadable-to-loadable-component ./src/client',
),
)

process.exit(1)
}

if (err.type === 'Unrecognised transform') {
console.error(chalk.red(`Unrecognised transform passed: '${err.payload}'`))

process.exit(2)
}

// For other errors, just re-throw it
throw err
}
9 changes: 9 additions & 0 deletions packages/codemod/bin/utils/CodemodError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class CodemodError extends Error {
constructor(args){
super(args);
this.type = args.type;
this.payload = args.payload;
}
}

module.exports = CodemodError;
35 changes: 35 additions & 0 deletions packages/codemod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "loadable-codemod",
"description": "Various codemods related to @loadable/components for easier migration/ugprades",
"version": "0.0.1",
"repository": "git@github.com:smooth-code/loadable-components.git",
"author": "Jacky Efendi <jacky.efendi1@gmail.com>",
"bin": {
"loadable-codemod": "./bin/main.js"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"react",
"ssr",
"webpack",
"code-splitting",
"react-router",
"server-side-rendering",
"dynamic-import",
"react-loadable",
"react-async-components",
"codemod"
],
"engines": {
"node": ">=8"
},
"license": "MIT",
"dependencies": {
"chalk": "^3.0.0",
"execa": "^3.3.0",
"jscodeshift": "0.6.4",
"yargs": "^14.2.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* eslint-disable */
import Loadable from 'react-loadable'

const CustomLinkLoadable = Loadable({
loader: () =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
loading: () => <div>loading...</div>,
delay: 0,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable */
import loadable from '@loadable/component'

const CustomLinkLoadable = loadable(() =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
fallback: (() => <div>loading...</div>)(),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* eslint-disable */
import Loadable from 'react-loadable'

const CustomLinkLoadable = Loadable({
loader: () =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
loading: (props) => {
if (props.error || props.timedOut) {
throw new Error('Failed to load custom link chunk')
} else if (props.loading) {
return <div>loading...</div>;
}
},
delay: 0,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-disable */
import loadable from '@loadable/component'

const CustomLinkLoadable = loadable(() =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
fallback: (props => {
if (props.error || props.timedOut) {
throw new Error('Failed to load custom link chunk')
} else if (props.loading) {
return <div>loading...</div>;
}
})({
pastDelay: true,
error: false,
timedOut: false,
}),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-disable */
import Loadable from 'react-loadable'

const Loading = props => {
if (props.error || props.timedOut) {
throw new Error('Failed to load custom link chunk')
} else {
return null
}
}

const CustomLinkLoadable = Loadable({
loader: () =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
loading: Loading,
delay: 0,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* eslint-disable */
import loadable from '@loadable/component'

const Loading = props => {
if (props.error || props.timedOut) {
throw new Error('Failed to load custom link chunk')
} else {
return null
}
}

const CustomLinkLoadable = loadable(() =>
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
fallback: Loading({
pastDelay: true,
error: false,
timedOut: false,
}),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
jest.autoMockOff();

const { defineTest } = require('jscodeshift/dist/testUtils');

defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_expr');
defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_arrow-no-params');
defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_arrow-w-params');
129 changes: 129 additions & 0 deletions packages/codemod/transforms/react-loadable-to-loadable-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/* eslint-disable no-param-reassign */
/* eslint-disable no-console */
const chalk = require('chalk')

const invokeWithMockedUpProp = (jscodeshift, file, prop) => {
// We invoke the function previously passed as `loading` to react-loadable with this props
// {
// pastDelay: true,
// error: false,
// timedOut: false,
// }
const j = jscodeshift

const defaultPropsObjProperties = []

defaultPropsObjProperties.push(
j.objectProperty(j.identifier('pastDelay'), j.booleanLiteral(true)),
)
defaultPropsObjProperties.push(
j.objectProperty(j.identifier('error'), j.booleanLiteral(false)),
)
defaultPropsObjProperties.push(
j.objectProperty(j.identifier('timedOut'), j.booleanLiteral(false)),
)

const defaultPropsObj = j.objectExpression(defaultPropsObjProperties)

const callExpr = j.callExpression(prop.value, [defaultPropsObj])

prop.value = callExpr

console.warn(
chalk.yellow(
`[WARN] '${file.path}' has some react-loadable specific logic in it. We could not codemod while keeping all the behaviors the same. Please check this file manually.`,
),
)
}

export default (file, api) => {
const { source } = file
const { jscodeshift: j } = api

const root = j(source)

// Rename `import Loadable from 'react-loadable';` to `import loadable from '@loadable/component';
root.find(j.ImportDeclaration).forEach(({ node }) => {
if (
node.specifiers[0] &&
node.specifiers[0].local.name === 'Loadable' &&
node.source.value === 'react-loadable'
) {
node.specifiers[0].local.name = 'loadable'
node.source.value = '@loadable/component'
}
})

// Change Loadable({ ... }) invocation to loadable(() => {}, { ... }) invocation
root
.find(j.CallExpression, { callee: { name: 'Loadable' } })
.forEach(path => {
const { node } = path
const initialArgsProps = node.arguments[0].properties
let loader // this will be a function returning a dynamic import promise

// loop through the first argument (object) passed to `Loadable({ ... })`
const newProps = initialArgsProps
.map(prop => {
if (prop.key.name === 'loader') {
/**
* In react-loadable, this is the function that returns a dynamic import
* We'll keep it to `loader` variable for now, and remove it from the arg object
*/
loader = prop.value

return undefined
}

if (prop.key.name === 'loading') {
prop.key.name = 'fallback' // rename to fallback

/**
* react-loadable accepts a Function that returns JSX as the `loading` arg.
* @loadable/component accepts a React.Element (what returned from React.createElement() calls)
*
*/
if (prop.value.type === 'ArrowFunctionExpression') {
// if it's an ArrowFunctionExpression like `() => <div>loading...</div>`,

if (
(prop.value.params && prop.value.params.length > 0) ||
prop.value.type === 'Identifier'
) {
// If the function accept props, we can invoke it and pass it a mocked-up props to get the component to
// a should-be-acceptable default state, while also logs out a warning.
// {
// pastDelay: true,
// error: false,
// timedOut: false,
// }

invokeWithMockedUpProp(j, file, prop)
} else {
// If the function doesn't accept any params, we can safely just invoke it directly
// we can change it to `(() => <div>loading...</div>)()`
const callExpr = j.callExpression(prop.value, [])

prop.value = callExpr
}
} else if (prop.value.type === 'Identifier') {
// if it's an identifier like `Loading`, let's just invoke it with a mocked-up props
invokeWithMockedUpProp(j, file, prop)
}

return prop
}

// for all other props, just remove them
return undefined
})
.filter(Boolean)

// add the function that return a dynamic import we stored earlier as the first argument to `loadable()` call
node.arguments.unshift(loader)
node.arguments[1].properties = newProps
node.callee.name = 'loadable'
})

return root.toSource({ quote: 'single', trailingComma: true })
}
Loading

0 comments on commit a82d5ad

Please sign in to comment.