Skip to content

Commit a82d5ad

Browse files
jackyefgregberge
authored andcommitted
feat: add codemods to migrate from react-loadable (#463)
1 parent 338bf55 commit a82d5ad

16 files changed

+672
-13
lines changed

Diff for: .eslintignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ dist/
44
lib/
55
build/
66
/website/.cache/
7-
/website/public/
7+
/website/public/
8+
__testfixtures__/

Diff for: .prettierignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ CHANGELOG.md
66
package.json
77
lerna.json
88
/website/.cache/
9-
/website/public/
9+
/website/public/
10+
__testfixtures__/

Diff for: packages/codemod/README.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# @loadable/codemod
2+
3+
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`
4+
5+
## Notes about `react-loadable-to-loadable-component` transform
6+
`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.
7+
8+
After running the codemod, you will still need to update some of your code manually, namely:
9+
1. Using `loadableReady` to hydrate your app on the client side.
10+
2. Updating your webpack configuration to use `@loadable`
11+
3. Updating your server side rendering code to use `ChunkExtractor`

Diff for: packages/codemod/bin/main.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env node
2+
3+
/* eslint-disable no-console */
4+
const yargs = require('yargs')
5+
const execa = require('execa')
6+
const path = require('path')
7+
const fs = require('fs')
8+
const chalk = require('chalk')
9+
const CodemodError = require('./utils/CodemodError')
10+
11+
const jscodeshiftExecutable = require.resolve('.bin/jscodeshift')
12+
const transformsDir = path.resolve(__dirname, '../transforms')
13+
14+
const { argv } = yargs
15+
16+
try {
17+
const selectedCodemod = argv._[0]
18+
const directoryToApplyTo = argv._[1]
19+
20+
if (!selectedCodemod || !directoryToApplyTo) {
21+
throw new CodemodError({
22+
type: 'Invalid params',
23+
})
24+
}
25+
26+
const availableTransforms = fs
27+
.readdirSync(transformsDir)
28+
.filter(v => v !== '__tests__' && v !== '__testfixtures__')
29+
.map(v => v.replace('.js', ''))
30+
31+
if (!availableTransforms.some(t => t === selectedCodemod)) {
32+
throw new CodemodError({
33+
type: 'Unrecognised transform',
34+
payload: selectedCodemod,
35+
})
36+
}
37+
38+
const result = execa.commandSync(
39+
`${jscodeshiftExecutable} --parser babylon -t ${transformsDir}/${selectedCodemod}.js ${directoryToApplyTo}`,
40+
{
41+
stdio: 'inherit',
42+
stripEof: false,
43+
},
44+
)
45+
46+
if (result.error) {
47+
throw result.error
48+
}
49+
} catch (err) {
50+
if (err.type === 'Invalid params') {
51+
console.error(chalk.red('Invalid params passed!'))
52+
console.error(
53+
chalk.red(
54+
'loadable-codemod requires 2 params to be passed, the name of the codemod, and a directory to apply the codemod to.',
55+
),
56+
)
57+
console.error(
58+
chalk.red(
59+
'Example: npx loadable-codemod react-loadable-to-loadable-component ./src/client',
60+
),
61+
)
62+
63+
process.exit(1)
64+
}
65+
66+
if (err.type === 'Unrecognised transform') {
67+
console.error(chalk.red(`Unrecognised transform passed: '${err.payload}'`))
68+
69+
process.exit(2)
70+
}
71+
72+
// For other errors, just re-throw it
73+
throw err
74+
}

Diff for: packages/codemod/bin/utils/CodemodError.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CodemodError extends Error {
2+
constructor(args){
3+
super(args);
4+
this.type = args.type;
5+
this.payload = args.payload;
6+
}
7+
}
8+
9+
module.exports = CodemodError;

Diff for: packages/codemod/package.json

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "loadable-codemod",
3+
"description": "Various codemods related to @loadable/components for easier migration/ugprades",
4+
"version": "0.0.1",
5+
"repository": "[email protected]:smooth-code/loadable-components.git",
6+
"author": "Jacky Efendi <[email protected]>",
7+
"bin": {
8+
"loadable-codemod": "./bin/main.js"
9+
},
10+
"publishConfig": {
11+
"access": "public"
12+
},
13+
"keywords": [
14+
"react",
15+
"ssr",
16+
"webpack",
17+
"code-splitting",
18+
"react-router",
19+
"server-side-rendering",
20+
"dynamic-import",
21+
"react-loadable",
22+
"react-async-components",
23+
"codemod"
24+
],
25+
"engines": {
26+
"node": ">=8"
27+
},
28+
"license": "MIT",
29+
"dependencies": {
30+
"chalk": "^3.0.0",
31+
"execa": "^3.3.0",
32+
"jscodeshift": "0.6.4",
33+
"yargs": "^14.2.0"
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* eslint-disable */
2+
import Loadable from 'react-loadable'
3+
4+
const CustomLinkLoadable = Loadable({
5+
loader: () =>
6+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
7+
loading: () => <div>loading...</div>,
8+
delay: 0,
9+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* eslint-disable */
2+
import loadable from '@loadable/component'
3+
4+
const CustomLinkLoadable = loadable(() =>
5+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
6+
fallback: (() => <div>loading...</div>)(),
7+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* eslint-disable */
2+
import Loadable from 'react-loadable'
3+
4+
const CustomLinkLoadable = Loadable({
5+
loader: () =>
6+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
7+
loading: (props) => {
8+
if (props.error || props.timedOut) {
9+
throw new Error('Failed to load custom link chunk')
10+
} else if (props.loading) {
11+
return <div>loading...</div>;
12+
}
13+
},
14+
delay: 0,
15+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/* eslint-disable */
2+
import loadable from '@loadable/component'
3+
4+
const CustomLinkLoadable = loadable(() =>
5+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
6+
fallback: (props => {
7+
if (props.error || props.timedOut) {
8+
throw new Error('Failed to load custom link chunk')
9+
} else if (props.loading) {
10+
return <div>loading...</div>;
11+
}
12+
})({
13+
pastDelay: true,
14+
error: false,
15+
timedOut: false,
16+
}),
17+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/* eslint-disable */
2+
import Loadable from 'react-loadable'
3+
4+
const Loading = props => {
5+
if (props.error || props.timedOut) {
6+
throw new Error('Failed to load custom link chunk')
7+
} else {
8+
return null
9+
}
10+
}
11+
12+
const CustomLinkLoadable = Loadable({
13+
loader: () =>
14+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'),
15+
loading: Loading,
16+
delay: 0,
17+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* eslint-disable */
2+
import loadable from '@loadable/component'
3+
4+
const Loading = props => {
5+
if (props.error || props.timedOut) {
6+
throw new Error('Failed to load custom link chunk')
7+
} else {
8+
return null
9+
}
10+
}
11+
12+
const CustomLinkLoadable = loadable(() =>
13+
import(/* webpackChunkName: "custom-link" */ '@components/CustomLink/Link'), {
14+
fallback: Loading({
15+
pastDelay: true,
16+
error: false,
17+
timedOut: false,
18+
}),
19+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
jest.autoMockOff();
2+
3+
const { defineTest } = require('jscodeshift/dist/testUtils');
4+
5+
defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_expr');
6+
defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_arrow-no-params');
7+
defineTest(__dirname, 'react-loadable-to-loadable-component', null, 'react-loadable-to-loadable-component_arrow-w-params');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/* eslint-disable no-param-reassign */
2+
/* eslint-disable no-console */
3+
const chalk = require('chalk')
4+
5+
const invokeWithMockedUpProp = (jscodeshift, file, prop) => {
6+
// We invoke the function previously passed as `loading` to react-loadable with this props
7+
// {
8+
// pastDelay: true,
9+
// error: false,
10+
// timedOut: false,
11+
// }
12+
const j = jscodeshift
13+
14+
const defaultPropsObjProperties = []
15+
16+
defaultPropsObjProperties.push(
17+
j.objectProperty(j.identifier('pastDelay'), j.booleanLiteral(true)),
18+
)
19+
defaultPropsObjProperties.push(
20+
j.objectProperty(j.identifier('error'), j.booleanLiteral(false)),
21+
)
22+
defaultPropsObjProperties.push(
23+
j.objectProperty(j.identifier('timedOut'), j.booleanLiteral(false)),
24+
)
25+
26+
const defaultPropsObj = j.objectExpression(defaultPropsObjProperties)
27+
28+
const callExpr = j.callExpression(prop.value, [defaultPropsObj])
29+
30+
prop.value = callExpr
31+
32+
console.warn(
33+
chalk.yellow(
34+
`[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.`,
35+
),
36+
)
37+
}
38+
39+
export default (file, api) => {
40+
const { source } = file
41+
const { jscodeshift: j } = api
42+
43+
const root = j(source)
44+
45+
// Rename `import Loadable from 'react-loadable';` to `import loadable from '@loadable/component';
46+
root.find(j.ImportDeclaration).forEach(({ node }) => {
47+
if (
48+
node.specifiers[0] &&
49+
node.specifiers[0].local.name === 'Loadable' &&
50+
node.source.value === 'react-loadable'
51+
) {
52+
node.specifiers[0].local.name = 'loadable'
53+
node.source.value = '@loadable/component'
54+
}
55+
})
56+
57+
// Change Loadable({ ... }) invocation to loadable(() => {}, { ... }) invocation
58+
root
59+
.find(j.CallExpression, { callee: { name: 'Loadable' } })
60+
.forEach(path => {
61+
const { node } = path
62+
const initialArgsProps = node.arguments[0].properties
63+
let loader // this will be a function returning a dynamic import promise
64+
65+
// loop through the first argument (object) passed to `Loadable({ ... })`
66+
const newProps = initialArgsProps
67+
.map(prop => {
68+
if (prop.key.name === 'loader') {
69+
/**
70+
* In react-loadable, this is the function that returns a dynamic import
71+
* We'll keep it to `loader` variable for now, and remove it from the arg object
72+
*/
73+
loader = prop.value
74+
75+
return undefined
76+
}
77+
78+
if (prop.key.name === 'loading') {
79+
prop.key.name = 'fallback' // rename to fallback
80+
81+
/**
82+
* react-loadable accepts a Function that returns JSX as the `loading` arg.
83+
* @loadable/component accepts a React.Element (what returned from React.createElement() calls)
84+
*
85+
*/
86+
if (prop.value.type === 'ArrowFunctionExpression') {
87+
// if it's an ArrowFunctionExpression like `() => <div>loading...</div>`,
88+
89+
if (
90+
(prop.value.params && prop.value.params.length > 0) ||
91+
prop.value.type === 'Identifier'
92+
) {
93+
// If the function accept props, we can invoke it and pass it a mocked-up props to get the component to
94+
// a should-be-acceptable default state, while also logs out a warning.
95+
// {
96+
// pastDelay: true,
97+
// error: false,
98+
// timedOut: false,
99+
// }
100+
101+
invokeWithMockedUpProp(j, file, prop)
102+
} else {
103+
// If the function doesn't accept any params, we can safely just invoke it directly
104+
// we can change it to `(() => <div>loading...</div>)()`
105+
const callExpr = j.callExpression(prop.value, [])
106+
107+
prop.value = callExpr
108+
}
109+
} else if (prop.value.type === 'Identifier') {
110+
// if it's an identifier like `Loading`, let's just invoke it with a mocked-up props
111+
invokeWithMockedUpProp(j, file, prop)
112+
}
113+
114+
return prop
115+
}
116+
117+
// for all other props, just remove them
118+
return undefined
119+
})
120+
.filter(Boolean)
121+
122+
// add the function that return a dynamic import we stored earlier as the first argument to `loadable()` call
123+
node.arguments.unshift(loader)
124+
node.arguments[1].properties = newProps
125+
node.callee.name = 'loadable'
126+
})
127+
128+
return root.toSource({ quote: 'single', trailingComma: true })
129+
}

0 commit comments

Comments
 (0)