Migrating to Lerna - Part 1
Posted on May 11, 2019 -  đšđ»âđ»Â 16 min readIn this post weâll understand why at some point a component library needs to migrate from a single package to multi-package architecture. Weâll also see how we can transform our code to support this infrastructure.
Where do we begin?
This is part 1 of a 3 part series
- Part 1 - Where do we begin?
- Part 2 - Supporting the development environment
- Part 3 - Publish all of the components!
Preface - the problem with a single package
You have an awesome Component Library, it started small, few components here and there. At that point it was fine, it was manageable, what could go wrong with just wrapping a few native elements with your own UI language?
After some time, while you didnât even notice, things got out of hand. You now have 100+ components, ranging from custom Header for all apps of your product to Rich Text Editors and virtualized tables.
You have one package to rule them all and itâs getting very very hard. You now want to change something in Button
, it means the entire package needs a new major version, even if most of the stuff havenât changed.
Your users are probably arenât happy with that.
Letâs say you donât have an automatic publish in CI (thatâs OK if you donât, but you should definitely consider it). You only publish at certain times when you feel itâs ready. This means that between two following versions there could be a changes to more than one component.
For instance, you added features in Button
and DatePicker
and decided to publish a new version. But oh no! you now realized that thereâs a regression in DatePicker
, unfortuantely you donât have the resources to handle it right now so this bug will remain there for some time.
Now think about it like a user of this component library, you want the new Button
feature but obviously you donât want the DatePicker
bug. Your only option is to wait for this bug to get fixed. So you see, thatâs not a good experience.
Your users are probably arenât happy with that either.
All of this can be solved. Instead of publishing all of your components in a single package, what if we publish each component in itâs own package? That way if one component changes it will only affect that component*.
Your users can choose to install a specific version for each component, they can now both avoid the DatePicker
bug AND get the Button
feature!
* - It will also affect all other components that depend (both directly & indirectly) on that component.
Lerna to the rescue
If youâll recall, we said things got out of hand and we have 100+ components, how are we ever going to publish 100+ packages?? If youâve had some research (or just looked at the title), you probably realized that the tool to help us with that is no other than - lerna
.
lerna
is a tool to help you manage multiple packages in a single repository. Sounds simple enough, right? Visit https://lernajs.io/ and update your repository to it and youâre done! thatâs it!
You didnât really think it was that simple, right? I do encourage you to go and visit the lerna
docs, you wonât find a migration guide - how to turn an existing codebase from one huge package to many multiple packages. Isnât it odd though? a tool thatâs meant to help you with a certain type of infrastructure doesnât have a migration guide towards that infrastructure?
My guess would be that it differs between each type of project, and that itâs just too broad of a subject to cover. Since youâre here, you probably care about a component-library type of project. This means that youâre in luck! Iâm going to help you migrate your component library codebase so that lerna
could take it from there and manage it for you.
This of course, wonât be an exhuastive guide (the fact that itâs 3 parts shouldâve already hinted it), but Iâm going to cover a lot of topics so buckle up!
Diving deep - defining our basic structure
First thingâs first, letâs define our current design & folder structure, that way we can understand how it needs to be changed when moving to lerna
, your design & structure may differ but probably not by much.
This is the basic folder structure:
components
|
|--- Button
| |
| |--- Button.js
| |--- Button.css
| |--- index.js
| |--- tests
| |
| |--- Button.spec.js
| |--- Button.visual.spec.js
|
|--- DatePicker
| |
| |--- DatePicker.js
| |--- DatePicker.css
| |--- index.js
| |--- tests
| |
| |--- DatePicker.spec.js
| |--- DatePicker.visual.spec.js
...
We have a folder for each component so everything related to a component is encapsulated.
You may be using scss
or some CSS-in-JS tool to style your components, you may be using jest snapshots, storybook or styleguidist - it doesnât matter, as long as thereâs a clear way of determining what files belong to which component. Here weâre using the folders/file names to do just that.
With this folder structure we have also defined the way we import components. For instance, in IconButton.js
we could see this:
import Button from '../Button';
...
We now know the basics. We know how the components interact with each other and what are the boundaries of each of them. We can now start to think about the changes needed for the migration.
Diving deep - plan ahead
lerna
has a concept of packages, which means that you can define some root folder(s) where all of your managed packages are. This conveniently is our components
folder - not much to do here but just tell lerna
that this folder will now contain all of our packages.
Sometimes, it wonât be this easy, you might have some general helpers located elsewhere or other graphic assets you also want to encapsulate in packages. In this case you have two options, either move everything to a new folder called packages
, or if it makes sense add another glob to the packages array in lerna.json
to capture the other packages.
That is probably the easiest part of the migration, where all you have to do is move a bunch of folders around and change some configuration. Now for the fun part, letâs talk Regexes and ASTs!
Since every component is going to be packaged individually, it wonât be able to relatively import other components anymore. So, to fix that weâll need to update those relative imports, theyâre now going to be package imports for the right pacakge within our monorepo.
Instead of going through each and every file in our repo that contains 100+ components (that would be a very tedious and error prone way to do it) weâll write a script. That script will go over all of the files and updates those import paths. There are a few advantages of using a script:
- you can easily revert the changes and recreate them in case of a mistake.
- Youâll probably update most, if not all files in the repo, the chance of conflicts is much higher. With the help of the script you can revert the changes, pull the latest code and re-run the script - no conflicts!
The question remains though, how do we do all of these changes in a script? We have two options, Transforming them with ASTs or using Regexes.
In the project I migrated, I used Regexes because I initially thought (also, I didnât really plan ahead):
just need to take
from '../Button';
and turn that tofrom '@my-scope/my-button';
, thatâs fairly simple.
Boy was I wrong. Turns out I had a whole bunch of types of imports and it wasnât that simple. Some types look the same but the context is different (import of a file in a test folder looks the same as an actual component import, etc.).
There is an upside to using Regexes though, itâs super fast - updating 100+ components (~500 files) was just a couple of seconds. Plus, you finally learn Regexes for real, you know how to read & write them which is a very useful skill in itself!
The other option is using an AST. A little primer on AST - AST is short for Abstract Syntax Tree, and itâs a way to represent a language through a data structure. ASTs are used within compilers to transform your code to another language, mostly to machine language.
Some of the most popular tools in web development use ASTs, webpack
, babel
, rollup
, they all use some kind of AST to read your code and transform it somehow. We can take advantage of these tools to create an AST for each of our files and produce a new file with the imports transformed.
No matter what you choose, youâll probably have to do at least a little matching. So letâs see how you get from import Button from '../Button';
to import Button from '@my-scope/my-button';
.
Diving deep - Regex
The Regex weâre going to use is /'(\.\.\/([^\/]*)(.*))';$/
. Now letâs break it down.
'\.\.\/
- weâre just matching the relative path. Since all of our components are placed flat under the same directory this relative path âidentifiesâ a component import. Make sure you apply your identification of a component here.([^\/]*)
- This is a capturing group, youâll later see why we need to capture this part. here we match every character thatâs not/
, so in the case of'../Button';
this will beButton
(weâre later matching';
).(.*)
- This is another capturing group, this time we match every character. With that, this group doesnât even have to exist because*
matches zero or more times.';$
- This just matches the end of the import line making sure there are no extra characters weâre matching here.
Note: notice thereâs another capturing group surrounding the entire import path - itâs there so we can replace the entire thing with whatever we want.
Now, why do we need these capturing groups? Thatâs because we need to replace the import using these captured groups
- First capture group allows us to replace the entire import. We will use a lesser known signature of
String.prototype.replace
that takes a method.
const newImport = import.replace(componentMatcher, (entireMatch, importPath, componentName, restPath) => {
return entireMatch.replace(importPath, ...);
});
It may look a bit confusing, but think of it this way âIn the import statement, match it with component statements, now replace each component match by taking the entire match and replacing the import path with something elseâ.
- Second capture group we get the component name which we can easily turn to
@my-scope/my-button
, as simple as
const packageName = `@my-scope/my-${kebabCase(componentName)}`;
- Third capture group is for cases where you might be doing this
import ButtonType from '../Button/Types';
. We want to maintain these imports, so we need to make sure to keep it. But now, each package will reference a âproductionâ version of other packages. You canât just use the samerestPath
, you would also need to include the build folder. So it would turn to something like this
const additionalPath = restPath ? `/es${restPath}` : '';
All together now!
const newImport = import.replace(componentMatcher, (entireMatch, importPath, componentName, restPath) => {
const packageName = `@my-scope/my-${kebabCase(componentName)}`;
const additionalPath = restPath ? `/es${restPath}` : '';
return entireMatch.replace(importPath, packageName + additionalPath);
});
There are probably other variants of imports in your project, for instance if youâre using scss
youâll need to look for @import
statements in scss
files as well, or if youâre within test files these kinds of relative imports arenât âcomponent importsâ.
Some import types I saw doing my own migration:
- Normal import -
import X from 'x';
- Depceptively normal import -
import X from "x";
(notice the"
) - CJS import -
const X = require('x');
- SCSS import -
@import 'x';
- Jest mock -
jest.mock('x', ...);
- Dynamic import -
import('x');
(this could also include webpack magic comments )
You now have the basic tools to transform your imports to their new way.
Whatâs next?
Next up - collecting dependencies.
Diving deep - collecting dependencies
Itâs nice that we updated all of our imports to their new variants, but does your package manager actually know about them? nope.
Weâll collect all of these dependencies so later weâll be able to build a package.json
for each component.
How do we collect them you ask? easy, right where we replace them. We actually build the new package name, just need to add it to the list of dependencies of the component weâre currently working on.
const newImport = imports.replace(componentMatcher, (entireMatch, importPath, componentName, restPath) => {
const packageName = `@my-scope/my-${kebabCase(componentName)}`;
const additionalPath = restPath ? `/es${restPath}` : '';
const dependencies[packageName] = true;
return entireMatch.replace(importPath, packageName + additionalPath);
});
Assuming dependencies
gets cleared after each component, itâll contain all the new dependencies. But what about old dependencies? like external packages youâre using in the component, like lodash
or classnames
? we need to add them to our dependencies as well. For that weâll build another Regex!
/'([^\.][^\/]*).*';$/
'[^\.]
- This means that we want to match'
followed by any character thatâs NOT.
. This is because we donât want to match relative imports, and relative imports always begin with either../
or./
so an easy way to discard all of them is by not matching anything that starts with a.
.[^\/]*
- Like before, we want to match anything thatâs not/
and thatâs because itâs a directory separator, and if we just want external package name, we donât want to get the whole path..*';$
- Even though we only want the package name, we still need to match the entire import, so if youâre importing something from within a package likeimport bind from 'classnames/bind';
thereâs a âgapâ between the end of the statement (';
) and the package name (classnames
), the.*
will take care of matching it,
Note: The capturing group will capture âThe first character thatâs not .
and all the characters up to the first /
â which will be the external dependency.
Now, this time, instead of replacing the import we just need to extract the package name from it, so itâs much simpler
const [_, dependency] = /'([^\.][^\/]*).*';$/.exec(import);
dependencies[dependency] = true;
dependency
will contain just the package name, so we just add it to the map of dependencies of our package.
Now, weâre basically done!
Wait, what? really? sort of, now all of your code should do the right thing, but we still need to hook everything up. we still need package.json
for each component. Itâs time to put all the pieces together!
Letâs write the package.json
file for each component. What fields do we want to have in each of the package.json
s?
name
- Obviously the package name, taken from the directory weâre in.private
-false
, we want to publish this package.main
- This will be the index file in our build folder (something likees/index.js
)repository
- Copy this from the originalpackage.json
since this is a monorepo, this doesnât change.keywords
- Youâll probably want something like['react', 'component', 'library', componentName]
description
- Create a sentence that fits your component library and can easily include the component name.version
- You have two options here, either reset everything to1.0.0
or continue from the current version of your library, your choice.dependencies
- Remember the previous secion? where we collected the dependencies? thatâs what it was for. The only thing is that weâll need to copy the actual version from the mainpackage.json
. Also, remember to filter outreact
&react-dom
peerDependencies
- Weâll just addreact
&react-dom
(or any other framework/library youâre working with) with the version specificed in the mainpackage.json
.scripts
- These will be the scripts that can be run by each component, weâll cover them in the next post.
Even though this should theoretically be enough Iâd recommend you to also symlink your general configs to each of your packages. Stuff like .npmrc
, .npmignore
, .babelrc
, etc.
The reason I believe you should do that is each package should still be able to act as an individual package, it shouldnât rely on the fact that itâs managed by lerna. In the off chance that a package grows big enough to be extracted to its own repo youâd have a relatively easy time doing it - itâs basically copy-paste.
So why not just copy these files to each package then? because if one of them changes - you want all of them to change, thatâs why symlink is the best choice here.
Backward compatibility
If youâll take a closer look youâll notice that we have now broken the API of our previous âmainâ package. Now thereâs no one entry point for all of your packages, your consumers must install each package individually in order to use them, while previously they only had to install one package.
To fix that we can create a âmainâ package, this package wonât be any component but rather just export all of them, one by one. You probably already had that kind of file already, something like:
export { default as Button } from './Button';
export { default as DatePicker } from './DatePicker';
export { default as TextInput } from './TextInput';
...
Again, we have to deal with normalizing the imports, which means we also have to collect the dependencies and all the other stuff. This makes the âmainâ another package we need to incorporate within our transformation script. Make sure you replace the imports correctly - these imports might be a bit different (notice ./
instead of the regular ../
).
Youâre now supporting a âbackward compatibleâ package to give your consumers some peace of mind when they do actually upgrade.
Weâre now ready for our next challange - fixing our development environment.