Migrating a monorepo from Lerna to Yarn 3 with PnP and Zero Install

Posted by Gabriel Borges on August 23, 2021 · 18 mins read

After sitting through a 15 minute yarn install I said “enough is enough” and I decided to migrate to the recently announced Yarn 3 with PnP support to get rid of our install process. Yes, get rid of yarn install via Zero Install.

This post is verbose on purpose. I’ve searched for the errors Yarn was outputting multiple times and didn’t find much help online, so I am adding all the errors I had here so that I can save you some time 1. Also, I may refer to Yarn as Yarn Berry or Yarn 3, they all mean the same thing, as Berry is the name of the repository post-v1.

First step: migrating to Yarn 3

We’ve been using lerna for our JavaScript monorepo project 2. I would glady stay with lerna, but after some tries I couldn’t get lerna to work with yarn 3, and it seems it won’t be supporting the new yarn versions anytime soon. Well…

The migration guide from the Yarn team is great, I’ll take the liberty of copying it here for ease of access:

<yarn-migration>

  1. Run npm install -g yarn to update the global yarn version to latest v1
  2. Go into your project directory
  3. Run yarn set version berry to enable v2 (cf Install for more details)
  4. If you used .npmrc or .yarnrc, you’ll need to turn them into the new format (see also 1, 2)
  5. Add nodeLinker: node-modules in your .yarnrc.yml file (we will try to enable this later)
  6. Commit the changes so far (yarn-X.Y.Z.js, .yarnrc.yml, …)
  7. Run yarn install to migrate the lockfile
  8. Take a look at this article to see what should be gitignored
  9. Commit everything remaining

</yarn-migration>

On step 8 they recommend modifying your .gitignore file. Here I recommend the gitignore plugin from oh-my-zsh, which allows me to just run gi yarn >> .gitignore and forget about it. Also, if you plan on using Zero Install, go check that section for more on gitignores

Also go ahead and delete all yarn.lock files that aren’t in the root of your project. This took me some time to figure out, but Yarn looks for the yarn.locks outside the root folder to mark that dir as a possible node_modules-compatible sub-package.

Moving from .npmrc and .yarnrc to .yarnrc.yml

Yarn 3 completely ignores the .npmrc and .yarnrc files on your project. I was actually using those to connect with my private npm repository.

My current .npmrc contains:

which becomes a .yarnrc.yml (keep reading and you’ll know what issue does this excerpt hold here):

The .yarnrc contained a pointer to .yarn/releases/yarn-1.22.11.cjs, which I won’t be needing anymore. So I deleted both the .yarnrc and this file.

nohoist

We used the nohoist option in our package.json, which has a slightly different behavior in Yarn 3. Previously, you’d list in the nohoist option which packages should not be hoisted to the top-level node_modules folder, and all sub-packages would follow that. Now, you’d go to the specific package.json and set the installConfig option to avoid hoisting that whole workspace. This is a great change if you ask me, specially if you are using Create React App.

Since I only had "nohoist": [] there, I just deleted the property, but your case may be different.

Authenticating with the npm repository

This was hard to figure out.

The first thing you need to do after setting up your .yarnrc.yml is run yarn npm login. After you have this login setup, you need to open your ~/.yarnrc.yml (on your Home, not your project) and add npmAlwaysAuth: true under the login settings for the registry.

I tried to follow the documentation anyway I could but I kept getting either this error:

or this “Invalid authentication (as an anonymous user)” error:

I simply could not find anything online that would point me in the right direction here, it appeared as if Yarn simply gave up on trying to authenticate me with my registry. After trying for long I started inserting debugger statements inside the Yarn compiled source code, gave up on that, bit the bullet and downloaded the berry codebase to debug yarn from inside.

It turns out Yarn can’t merge your local registry config and your global config. At least I learned how to debug yarn?

How to debug Yarn berry itself

If you ever need to debug Yarn itself, this is how you do it:

  1. Clone github.com/yarnpkg/berry/;
  2. Run yarn install on the resulting dir;
  3. (Optional for macOS) run realpath scripts/run-yarn.js | pbcopy to copy this path to your clipboard
  4. Go to your original folder (where the bad things are happening);
  5. Change the yarnPath entry in .yarnrc.yml to /path/to/your/clone/berry/scripts/run-yarn.js (or just paste it if you used the command above)

Now whenever you run yarn inside this project folder you will use the development version. Props to the yarn team for using node-ts to load the typescript files on the go, that helped me a lot.

Second Step: From lerna to yarn

In order to migrate from lerna we need to add a new plugin to our environment:

lerna.json

While it is installing the plugin, let’s go ahead and delete our lerna.json file, remove all scripts that run lerna, and remove the dependency from our packages. If you only had your subpackages listed in lerna.json, it is time to migrate it to Yarn’s ``package.json`:

The workspace: protocol

Yarn berry now supports a few things that lerna didn’t, one of those things is explicitly setting the package imports to always match the ones on your monorepo. This means that it doesn’t matter when or where this package is run, it will only work when running inside the monorepo. If your CI/CD are ready to deal with monorepos, this can be a great thing, and you can go ahead and change the version of your packages to workspace:* (this will match the folder you’re in). Unfortunately, that is not my case and I’ll have to go without this.

The yarn start command

In many React projects we have the start script ready to get things going. When using lerna we used to have the following command to use it in our monorepo:

This would simply execute all scripts named start in our subpackages. While okay, it did waste resources by running start on unnecessary packages.

Now, with Yarn berry we can run, directly in the package we need:

This command come from the workspace-tools plugin I’ve mentioned. It is using these options:

  • v: verbose, prefix the output with the package that printed that;
  • p: run in parallel;
  • i print all outputs in realtime;
  • R: recursive.
    • Recursive is the real beauty here. It will traverse your dependencies/devDependencies and only run start on the packages that actually need to be started!

Recursive explained

Here’s an example. A workspace have these packages, all with a start script in their package.json file:

  • @my/a, importing:
    • @my/b
    • @my/c
  • @my/d, importing:
    • @my/b
    • @my/d
    • @my/e.

With lerna, all of these packages’ start script would run, no matter if I am working only on @my/a right now. With the recursive option, if I run it inside @my/a, it will only run start on @my/a, @my/b, and @my/c, leaving @my/d and @my/e alone.

It turns out that when running the start command mentioned above, one can get the following error:

As mentioned in the beginning, you need to delete the yarn.lock files inside all subpackges on yarn 3, something that yarn 1 created.

If you are following along with your repo, I recommend you to commit your changes now, as this has been some work already!

Third Step: Moving to PnP

You may not need PnP, you may need to evaluate if the gains from enabling PnP are really worth it for you, there are a lot stuff that breaks when migrating… But I am here for the Zero Install! Well, once again, the migration tutorial covers a lot of the use cases, lets copy it again:

<yarn-migration>

Enabling it
  1. Look into your .yarnrc.yml file for the nodeLinker setting
  2. If you don’t find it, or if it’s set to pnp, then it’s all good: you’re already using Plug’n’Play!
  3. Otherwise, remove it from your configuration file
  4. Run yarn install
  5. Various files may have appeared; check this article to see what to put in your gitignore
  6. Commit the changes
Editor support

We have a dedicated documentation, but if you’re using VSCode (or some other IDE with Intellisense-like feature) the gist is:

  1. Install the ZipFS VSCode extension
  2. Make sure that typescript, eslint, prettier, … all dependencies typically used by your IDE extensions are listed at the top level of the project (rather than in a random workspace)
  3. Run yarn dlx @yarnpkg/sdks vscode
  4. Commit the changes - this way contributors won’t have to follow the same procedure
  5. For TypeScript, don’t forget to select Use Workspace Version in VSCode

</yarn-migration>

I didn’t know which deps should be on the root or not when I ran the step #2 for the editor support. So I did like any dev would and looked at the code to see which tools are supported by it. In my case I only needed to move typescript, eslint and prettier (my CSSs are written by another team).

Another caveat I bumped into was that after doing all that, my VSCode’s TypeScript still didn’t find my files. Turns out that if you have a .code-workspace file, VSCode will load the settings from there! So I added the settings that Yarn generated in that file and voila!

Fourth Step: PnP Issues

If you have a build script in all your packages, you can run

and it will show you if your build is running or not. Turns out that mine wasn’t well.

One of the packages I depend on didn’t declare their dependencies correctly. In order to fix that I was tempted to add their dependency in my root’s package.json, well, this broke other stuff in my project because the mere installation of that particular package interfered with the generation of the html on my compilation (yeah).

The correct approach here is to use Yarn’s package extension feature to correctly patch their package.json and load the package they are missing.


My next headache was with eslint. I first went with the dumb route and started adding my configurations in the root folder, but it wasn’t cutting it. In my setup I have a package called @mycompany/eslint-config-react for my React apps, and this package is meant to be the only eslint config place for the project. To make eslint work you need to add @rushstack/eslint-patch to your configuration project, and on your .eslintrc.js (or index.js) you have to add:

In my project this highlighted a bunch of plugins I was using but didn’t declare on my package.json.


After adding all those plugins, I have the following error:

Running eslint directly yielded a better error log:

Fixing this was simple: upgrade this rowdy package (really they’ve fixed it yesterday 😂).


On to the eslint/jest error:

To fix this, set the jest version:


eslint-plugin-import has some issues with PnP.

eslint-plugin-import needs to know how to resolve the packages, it should do that internally, but it tries to load the resolver from the project that the linted files are in. This makes things weird here on PnP because we do not have all files dumped on the same dir. Usually the resolution is to add eslint-import-resolver-node as a direct dev dependency. Since we are using workspaces it must be added on the root package.json.

Fifth Step: Enabling zero install

Before enabling zero installs to speed up your dev lifecycle, clone your project anew and make a clean install to see if you are on the green.

You can follow Yarn’s guide here to un-ignore the cache files. Adding to your .gitignore the following:

Have you noticed there are some packages that compile some stuff after you install them? Think fsevents. Well, after they compile, Yarn dumps their content unto .yarn/unplugged.

You have two ways to deal with this:

  • The Yarn team recommends you to ignore this folder.
    • If you are going to ignore this folder, you need to add enableScripts: false to your .yarnrc.yml file so that no package can ever add something there.
  • If, however, you need the files generated, you’ll have to add !.yarn/unplugged to your .gitignore folder, and then git add -f .yarn/unplugged.

In my case, I went with the ignore/deletion route, but you should test your project to see what to do.

Conclusion

After doing all that work (seriously it took me days). We need to check if it works! Clone your project and try to build/start it! If it doesn’t work, well, you’ll have to dig deeper.

Was it worth my investment? In my context, the time saved by having Zero Install is surely worth it, people can focus more on their code and less on their tooling.

Seriously, let me know if you have problems (post on StackOverflow and send me a link), or if I’ve helped you at all.

Next Steps

Things I didn’t cover, but you might need:

  • versioning
  • detecting changes for rebuilding only the necessary packages
  • publishing the packages
  1. If this has helped you in any way please let me know on Twitter. I like to know when I help people.

  2. Currently just a React application with multiple Create React App and microbundle libraries