How we converted CoffeeScript files with about 220 React components to JavaScript
On June 11, 2018 I created a new issue Rewrite from CoffeeScript to JS/JSX to get rid of CoffeeScript in our biggest repository for Keboola Connection UI .
Why?
JavaScript and its ecosystem already provide features and tools we need. These features (arrow functions, object spread operator, destructuring, etc.) are stable enough and there’s no need to write code in another language and convert it to JavaScript. Maybe in TypeScript, but that’s another story.
History
Before I created the issue, we were already converting CoffeeScript to JavaScript. We had a non-written rule in our team that when you need to touch .coffee
file, you should turn it to .js(x)
first. We didn’t standardize this process in any way, so someone converted it using the CLI, someone using the online tool js2.coffee.
Later we tuned that process a bit. We started writing tests/snapshots to be sure we are not breaking things. Check the article The worry-free way to convert CoffeeScript to JSX to find out more.
This process wasn’t so bad. The biggest drawback was that we needed to rewrite React components almost from scratch after every conversion.
Our sample CoffeeScript looked like this:
React = require 'react'
{ div, p } = React.DOM
module.exports = React.createClass
render: ->
div null,
p className: 'text-center',
'Hello World!'
And converted by the online tool js2coffee:
var React, div, p, ref;
React = require('react');
ref = React.DOM, div = ref.div, p = ref.p;
module.exports = React.createClass({
render: function() {
return div(null, p({
className: 'text-center'
}, 'Hello World!'));
}
});
As you can see, the render
function was very simple, but after the conversion we weren’t able to easily modify it. Every element was converted to a function.
Decaffeinate
In August 2018 I bumped into an article about a similar process: Converting a large React Codebase from CoffeeScript to ES6. It was a huge time saver for us. Especially for introduction to Decaffeinate — a tool for conversion CoffeeScript to JavaScript.
Decaffeeinate is very powerful, and after few tests, I found it can work for us.
decaffeinate --loose-includes --loose-default-params --loose-for-expressions --loose-for-of --use-js-modules sample.coffee
Produces this output:
import React from 'react';
const { div, p } = React.DOM;
export default React.createClass({
render() {
return div(null,
p({className: 'text-center'},
'Hello World!')
);
}
});
But the problem remains the same. Even if the code looks fine, has nice ES6 imports (no require
), nice default
ES6 export
, and no function
keyword, it still has functions instead of JSX elements.
Jscodeshift
I knew there’s a tool for automatically converting a file from one JavaScript syntax to another. It’s called Jscodeshift. I saw it at some conference.
Secondly, I also knew that Facebook provides scripts (called transform scripts or simply transforms) to convert React components from one syntax to another, so transitions to newer versions of React should be smoother.
I noticed that in the mentioned article about converting to ES6, they were using jscodeshift
too. So I started exploring various transforms in the react-codemod repository. Unfortunately, with no luck. I had to find another way.
I found the React-DOM-to-react-dom-factories.js transform script. It should be able to convert React.DOM.div()
calls to React.createElement('div')
calls. But we weren’t there yet.
Custom transform script
Then I decided to write a transformation script. To replace div()
calls by React.DOM.div()
calls and to remove variables assigned using React.DOM
object destructuring (const { div, p } = React.DOM;
).
I spent few hours playing with AST explorer — exploring AST generated from a JavaScript file from Decaffeinate output and programming my own transform script.
The transform script does this:
- It finds functions (if
path.name === 'callee'
, then it’s a function) with the DOM factory nameisDomFactory(path.node.name)
and adds the prefixReact.DOM.
. - Then it finds the parent for React.DOM
VariableDeclarator
and removes the whole line.
Which finally modifies our component to:
import React from 'react';
export default React.createClass({
render() {
return React.DOM.div(null,
React.DOM.p({className: 'text-center'},
'Hello World!')
);
}
});
See dom-factories-calls-to-calls-with-react-dom-prefix.js to check the full script.
Putting things together
After a successful conversion using a custom transform script, we can run the React-DOM-to-react-dom-factories.js:
import React from 'react';
export default React.createClass({
render() {
return React.createElement(
'div',
null,
React.createElement('p', {className: 'text-center'}, 'Hello World!')
);
}
});
And then another transform script create-element-to-jsx.js:
import React from 'react';
export default React.createClass({
render() {
return (
<div>
<p className="text-center">
Hello World!
</p>
</div>
);
}
});
Finally, we have a nice ES6 component with clean JSX syntax. Great.
All these steps can be put together into a simple shell script, which should be run in a directory with the .coffee
files you want to convert.
decaffeinate --loose-includes --loose-default-params --loose-for-expressions --loose-for-of --use-js-modules *.coffee
jscodeshift -t /code/codemod/transforms/dom-factories-calls-to-calls-with-react-dom-prefix.js *.js
jscodeshift -t /code/react-codemod/transforms/React-DOM-to-react-dom-factories.js *.js
jscodeshift -t /code/react-codemod/transforms/create-element-to-jsx.js *.js
How did it work for us?
We tried to convert all files at once, but after the conversion, there were about 3,300 Eslint errors. Ignoring them was not an option, so continuous transformation worked better.
The key was to create small pull requests with a conversion of a few modules to move fast without breaking things.
We already had experience with this. During migration from React 13 to React 14, we did it the same way.
To replace CoffeeScript, we created 96 pull requests and tracked progress automatically every day. A simple script checked a number of remaining files and created a Markdown file with stats. After that Travis CI built it using CRON job every day. Then it was published as a static website to Github Pages.
From the issue creation to the end, it took about five months and two weeks. But as you can see, a more significant decrease occurred already at the beginning of September. That’s when our colleague Jakub Kotek began working on the issue and pushed it forward really fast.
However, we weren’t working on it full-time. No one would have approved it. Ever. Because the immediate business value of the conversion itself is zero. No UI polishing, no speed improvements, nothing. Z-E-R-O. But it will allow us to do things a bit faster in the future.
Technically, there were small issues we found during conversion:
- Many components needed a manual replacement for
React.createFactory
(we decided not to write another transform script, because the manual update was really fast). - Replacement of
__guard__
functions created by Decaffeinate was a bit complicated sometimes. - We had to be careful with imports of
default
exports from new JS/JSX files. The imports had to be replaced in the remaining.coffee
files by adding the.default
suffix.
November 25th, 2018 was the last day we saw CoffeeScript in our main UI repository.