Writing codemods with jscodeshift
When working with large software projects, you will inevitably encounter situations where you need make updates that affect many pieces of your codebase. A common scenario is updating code that uses a deprecated API. You might also want to adopt a new language feature or pattern that improves the readability and/or performance of your code.
To complete these code migrations, you will often go in an make the necessary changes manually, which can be time consuming and tedious. Fortunately, there is a way to accomplish these types of migrations with code: codemods.
The idea behind codemods is to define a set of rules that dictate how to transform source code. Write the rules once, and then apply to all relevant files in your project.
Setting the stage
I'm part of the Web Platform team at Convoy, and I work on tools that help teams build web applications. One of the codebases that my team maintains is the design system library.
One of the product teams that I work with had noticed that development compile times had increased significantly when updating to a newer version of the design system.
After some investigation, we were able to narrow the problem down to a single component, the
Here's a simplified version of what the component looked like. Can you spot the problem?
The issue is the
import * as Icons from '@material-ui/icons' statement. Its causing all of the icon components from
@material-ui to be included in the development bundle.
This is a problem, because Material UI has a LOT of icon components. At the time of writing, Material UI has 5000+ icon components. That's 5000+ components that Webpack has to
process when creating a development bundle. Each icon component module is small, so its really a "death by 5000 cuts" scenario. The Material UI docs even have a guide,
with suggestions for how to mitigate the problem.
To demonstrate the impact, using my personal website as an example app, I added the following code to the main page:
Next, I ran the
@next/bundle-analyzer Webpack plugin on my development build, and got the following result:
Webpack includes all of the icon components from
Even if I'm using a single icon from
@material-ui/icons, the development bundle includes all 5000+ icons in it. 😱
Unfortunately, it gets even worse. Since we are using the
Icons[iconName] pattern, Webpack is unable to tree-shake unused icons
from our production bundle, and we end up with 5000+ icons in our production bundle. Ouch.
We need to solve the production issue first, since it negatively affects an end user's experience. We'll need to update our code so that the set of icons we need can be determined through static analysis at build time.
A possible solution would be to update our import statement to explicitly state the icons we want:
This will work for production, but not in development. It won't work for development, because Webpack will load the module at
which is a file that re-exports all of the individual icons. Webpack won't perform tree-shaking optimizations in development, so it will still include all of
the icons in the development bundle. We'll have to use a different approach.
Using path imports will address the problem for both development and production builds:
This made for a dramatic improvement to the size of the development bundle:
Webpack only includes the icon components I have specified in the bundle now!
Fixing our Icon component's API
With a solution to the underlying problem identified, we needed to update our approach to the design system's Icon API.
Instead of importing the Material UI icons in the design system library, we would have the consumers of the library provide the icons to the component. This allows product teams to have control over which icons they are importing into their bundles.
ℹ️ You can see what the API change would look like, with this interactive example
Passing in the icon name as a string, through the
After testing this change, we found that the start up times were significantly faster 🙌.
|Before (All Icons in Bundle)||After (Individual Icons in Bundle)|
|Modules compiled (dev):||5905||353|
|Compile time (dev):||11.2s||3.5s|
|Total bundle size (dev):||5.52 MB||2.62 MB|
A new problem
Making these changes introduces with a new problem: This was going to be a breaking API change, and every app that used the
Icon component would need to use the updated code.
We couldn't keep the old approach in, for backwards compatibility, since it would still result in apps importing all of the icons in development, even if they were using the new API.
Icon usage was pretty heavy in lots of apps, with some apps even having hundreds of instances 😱. On top of that, the migration wouldn't be as simple as just using find/replace, since we would need to add the import for the icon component from Material UI.
Enter codemods and jscodeshift!
This migration was a very good use case for learning how to write codemods using jscodeshift. We could define the codemods once, and then apply them to all of the applications that needed to be updated.
The key with codemods is to identify a common repeatable pattern, which you'll apply to all files in the project.
ℹ️ Use the controls below to see the steps that our codemod will take to transform the code
Open the file, so that we can find the code we'd like to transform
Building our codemod
Now that we have our general idea in place, we can start to write the codemod!
ℹ️ This interactive example will take us step by step through the process
The basis of a
jscodeshift codemod is a
Transform function, where we define the transformations that we
will be performing on the source code
Here's the entire implementation, with added comments:
With this codemod, we were able to update all the applications that used the design system. A couple hours to write the codemod saved days of manual migration work!
If you want to get started learning more about codemods, I highly recommend checking out AST Explorer. It allows you to: inspect the AST of your source code, write codemods, and apply those codemods to source code, all in the browser! Its a great tool for learning how ASTs are structured, and for prototyping codemods. There is an initial learning curve, but getting the basics down could save you a lot of time in your future code migration work.