I recently inherited a large mono-repo full of aging of React applications. As I talked about in the [Object assign to object spread codemod] post, some of these applications are going to live on and I'd like for them to be up to date with any newer React apps that are built.
Some of these applications have on the order of tens or hundreds of thousands of lines of code, so doing this upgrade by hand is entirely out of the question.
Luckily, the React engineers have provided developers a way to upgrade older React apps to newer versions using codemods!
When I created the object spread codemod, I had this React upgrade in my head the whole time but wanted to test out the feasibility of using something like this on a much larger scale. Now that I've seen what codemods are capable of and how easy they are to write, I'm very confident I can accomplish this in a very short amount of time.
Yea, I've got some old applications. Five of them are almost as old as React itself. The rest are slightly newer than the 15.5 release.
After some Googling, I found some blogs that hinted at some React codemods, however there wasn't an explicit upgrade methodology to the work they were doing.
I decided to see what the React release notes had to offer, and that's when I discovered the upgrade guides the React engineers had published.
Basically, there are 4 major points in time at which a React application is going to need to undergo some major work before the version can be upgraded.
I'm not going to cover additional upgrades in this post since my target release is 16.14.0. Someday I'll revisit this work when I decide to make the jump from 16 to 17 and 18.
The major changes here that affected me was the split of React and ReactDOM, as well as changing the way refs were instantiated.
There were also some splits of some helpful libraries.
I had been using react/lib/cx
for building more complex JSX classNames, and the recommended port was just to use classnames
.
npm i --save classnames@2.2.3 jscodeshift -t ~/codeshift/ConvertReactLibCx.js .
Basically this is probably way too verbose for just 1) finding the react/lib/cx
import declarations, 2) change the import specifier 3) and then finally change the method calls throughout the application callsites.
module.exports = function(fileInfo, { jscodeshift: j }, options) { const ast = j(fileInfo.source); const { comments, loc } = ast.find(j.Program).get('body', 0).node; j.__methods = {}; ast .find(j.VariableDeclaration, (node) => { return isRequire(node, 'react/lib/cx') }) .forEach(transformRequire(j)); return ast.toSource({ arrowParensAlways: true, quote: 'single', }); }; function isRequire(node, required) { return ( node.type === 'VariableDeclaration' && node.declarations.length > 0 && node.declarations[0].type === 'VariableDeclarator' && node.declarations[0].init && node.declarations[0].init.type === 'CallExpression' && node.declarations[0].init.callee && node.declarations[0].init.callee.name === 'require' && node.declarations[0].init.arguments[0].value === required ); } function transformRequire(j) { return (path) => { const identifier = path.value.declarations[0].id.name j(path).replaceWith( j.variableDeclaration('const', [j.variableDeclarator( j.identifier(identifier), j.callExpression( j.identifier('require'), [j.literal('classnames')] ) )] ) ); }; }
I also had a similar change to make for callsites using react/lib/keyMirror
, which I could just swap out the import keymirror
for with no additional code changes. Sweet.
npm i --save keymirror@0.1.1 jscodeshift -t ~/codeshift/ConvertReactLibKeymirror.js .
This one was easy since I didn't need to think in terms an explicit import, and I could just do a simple string replace again with some j.replaceWith
.
module.exports = (file, api, options) => { const j = api.jscodeshift; const printOptions = options.printOptions || { quote: "single" }; const root = j(file.source); root.find(j.Literal, { value: "react/lib/keyMirror" }).replaceWith(j.literal("keymirror")) return root.toSource(printOptions); };
React provides a codemod to implement ReactDOM, so I could save some time writing my own and just use their implementation, which is what I'm counting on for this entire process.
jscodeshift -t ~/react-codemod/transforms/react-to-react-dom.js .
You can view this transform here.
Before I finished this part, I had to go through some NodeJS templates and make sure I actually had a mounting point and that the React application wasn't mounting directly to document.body
.
The final codemod that I needed to run was to upgrade the refs callsites. React maintains a codemod for this and I tried to use it, however I was experiencing runtime issues that I had to fix before I could get a successful run.
jscodeshift -t ~/codeshift/ConvertFindDOMNode.js .
The imports and the original code I shamefully copied from React (sorry), which you can see below.
Honestly, I forgot what I changed but it was enough where I was able to get all of my refs upgraded with no errors.
function getDOMNodeToFindDOMNode(file, api, options) { const j = api.jscodeshift; require('./utils/array-polyfills'); const ReactUtils = require('./utils/ReactUtils')(j); const printOptions = options.printOptions || { quote: 'single', trailingComma: true }; const root = j(file.source); const createReactFindDOMNodeCall = arg => j.callExpression( j.memberExpression( j.identifier('ReactDOM'), j.identifier('findDOMNode'), false ), [arg] ); const updateRefCall = (path, refName) => { j(path) .find(j.CallExpression, { callee: { object: { type: 'Identifier', name: refName }, property: { type: 'Identifier', name: 'getDOMNode' } } }) .forEach(callPath => j(callPath).replaceWith( createReactFindDOMNodeCall(j.identifier(refName)) ) ); }; const updateToFindDOMNode = classPath => { var sum = 0; // this.getDOMNode() sum += j(classPath) .find(j.CallExpression, { callee: { object: { type: 'ThisExpression' }, property: { type: 'Identifier', name: 'getDOMNode' } } }) .forEach(path => j(path).replaceWith(createReactFindDOMNodeCall(j.thisExpression())) ) .size(); // this.refs.xxx.getDOMNode() or this.refs.xxx.refs.yyy.getDOMNode() sum += j(classPath) .find(j.MemberExpression, { object: { type: 'MemberExpression', object: { type: 'MemberExpression', object: { type: 'ThisExpression' }, property: { type: 'Identifier', name: 'refs' } } } }) .closest(j.CallExpression) .filter( path => path.value.callee.property && path.value.callee.property.type === 'Identifier' && path.value.callee.property.name === 'getDOMNode' ) .forEach(path => j(path).replaceWith( createReactFindDOMNodeCall(path.value.callee.object) ) ) .size(); // someVariable.getDOMNode() wherre `someVariable = this.refs.xxx` sum += j(classPath) .findVariableDeclarators() .filter(path => { const init = path.value.init; const value = init && init.object; return ( value && value.type === 'MemberExpression' && value.object && value.object.type === 'ThisExpression' && value.property && value.property.type === 'Identifier' && value.property.name === 'refs' && init.property && init.property.type === 'Identifier' ); }) .forEach(path => j(path) .closest(j.FunctionExpression) .forEach(fnPath => updateRefCall(fnPath, path.value.id.name)) ) .size(); return sum > 0; }; if (options['explicit-require'] === false || ReactUtils.hasReact(root)) { const apply = path => path.filter(updateToFindDOMNode); const didTransform = apply(ReactUtils.findReactCreateClass(root)).size() + apply(ReactUtils.findReactCreateClassModuleExports(root)).size() + apply(ReactUtils.findReactCreateClassExportDefault(root)).size() > 0; if (didTransform) { return root.toSource(printOptions); } } return null; } module.exports = getDOMNodeToFindDOMNode;
And finally, I can upgrade to React 0.14, install ReactDOM 0.14 and move on:
npm i --save react@0.14.0 react-dom@0.14.0
In what was the easiest upgrade step, I simply had to update the React version in package.json
, and then just run some simple regression tests.
npm i --save react@15.0 react-dom@15.0
This step was more focused on internal changes, and didn't really have breaking changes per-se.
There was a bugfix in React.cloneElement()
that sounded like would cause issues for developers that had relied on it being a feature, and lots of ReactPerf stuff that I didn't use was changing.
The React.createClass
deprecation was huge and basically was the only reason why so many of these applications had sat and rotted for years, basically because engineers and QA personnel were too scared to perform this upgrade by hand.
There was also a ton of work involved in upgrading things like PropTypes and getting rid of mixins.
First I can easily migrate from the React supported proptypes identifiers to the recommended npm modules. I'll install the module at the end of this step.
jscodeshift -t ~/react-codemod/transforms/React-PropTypes-to-prop-types.js .
The next part really wasn't necessary in order to perform the upgrade, but it was really a style thing in terms of how I wanted to the source code to be organized.
I had to do this after I ran the React.PropTypes
codemod since doing it beforehand makes this codemod much more complicated in terms of how the identifiers are structured etc.
// codeshift all proptypes out of the React.createClass object jscodeshift -t ~/codeshift/MovePropTypes.js .
module.exports = (file, api, options) => { const j = api.jscodeshift; const printOptions = options.printOptions || { quote: "single" }; const root = j(file.source); let reactCreateClassObj; let propTypesObj; return root .find(j.Program) .forEach((path) => { // Find React.createClass let reactCreateClassPath; j(path) .find(j.CallExpression, { callee: { type: "MemberExpression", object: { name: "React" }, property: { name: "createClass" }, }, }) .forEach((path) => { reactCreateClassPath = path.parentPath; }); if (typeof reactCreateClassPath === "undefined") return; if ( typeof reactCreateClassPath.value === "undefined" || typeof reactCreateClassPath.value.id === "undefined" ) { console.warn( `Found createClass but was not attached to an identifier @ "${file.path}"` ); return; } const reactComponentIdentifierName = reactCreateClassPath.value.id.name; let propTypesObjectExpression; let propTypesObjectExpressions = j(reactCreateClassPath) .find(j.ObjectExpression) .filter((path) => path.parent.parent.value.type === "ObjectExpression") .forEach((path) => { propTypesObjectExpression = path.value; }); j(reactCreateClassPath).forEach((x) => { j(x) .find(j.Property, { key: { type: "Identifier", name: "propTypes", }, }) .forEach(path => { path.parentPath.value.filter(property => property.key.name === 'propTypes' ) .forEach(property => { propTypesObjectExpression = property.value; }) }) .remove(); // Remove `propTypes` from the `React.createClass` object }); if (typeof propTypesObjectExpression === "undefined") return; const reactComponentAssignment = j.expressionStatement( j.assignmentExpression( "=", j.memberExpression( j.identifier(reactComponentIdentifierName), j.identifier("propTypes") ), propTypesObjectExpression ) ); j(reactCreateClassPath.parent).insertAfter(reactComponentAssignment); }) .toSource(printOptions); };
One good thing about this step was I only ended up having to write a codemod to get rid of PureRenderMixin, and convert it to React.PureComponent
.
Now that PureRenderMixin
is converted, React
// Convert PureRenderMixin to `shouldComponentUpdate` (if necessary) jscodeshift -t ~/stevus/react-codemod/react-codemod/transforms/pure-render-mixin.js .
After all of that work is done, I can finally move to the Class syntax of declaring React components.
jscodeshift -t ~/stevus/react-codemod/react-codemod/transforms/class.js .
React.createClass to Class codemod here
Since react-proptypes
is no longer a thing, I have to install the recommended external module:
npm i --save prop-types@15.5.7
Finally I upgraded React.
npm i --save react@15.5 react-dom@15.5
This last step was similar to the React 15.0 upgrade, in that it was really simple to implement and run.
The React engineers literally stated:
if your app runs in 15.6 without any warnings, it should work in 16.
So I felt good about this step.
I didn't have to write any codemods, however I did need to use some find and replace, which is a very basic form of codemod everyone is familiar with.
The only codemod I needed to run was one provided by React renaming some lifecycle methods which now trigger deprecation warnings.
jscodeshift -t ~/react-codemod/transforms/rename-unsafe-lifecycles.js .
I've linked to this codemod here
Installing React 16.14.0 is simple, just run an npm install
and be on your way.
npm i --save react@16.14.0 react-dom@16.14.0
Some of the applications I worked on were also on ancient versions of Flux JS, so they used sunsetted versions of react/lib/update
and needed to be replaced by a supported 3rd party library.
npm i --save immutability-helper@3.1.1
And that's it!
I'd say in all, this process took me a couple of weeks. I had to experiment with the right order of when to call the codemods, as well as installing new modules. Also, finding the times existing codemods didn't work ate up a lot of time since I had to decide how long I was willing to wait until I ust wrote my own version.
React does a great job providing upgrade support in the form of codemods, and the release documentation is excellent. Without that, I wouldn't have been able to complete this project and would be stuck with a bunch of old React applications which would eventually need to be rewritten, which I'm not a fan of.