Today was a busy day for me. Scheduled to do one project, but actually doing another. Helping several teammates get through their work, while also getting flooded with meetings at the same time. And it doesn't help when I'm in multiple chats and someone suddenly asks me something totally unrelated to the work I'm currently doing. I'm running on fumes, I can't even get myself to focus on anything.
Then there's Yarn. Yes, the package manager.
Several months ago, it got the new and shiny treatment from the team and gained a lot of hype. A lot of devs highlighted its benefits over npm like package caching, install speed, the revolutionary lock file, workspaces, package hoisting, being community-driven and not company-driven, yadda yadda yadda. It was the dream of every dev that I even wrote a company blog about using it. Little did I know it would come back to haunt me.
React was the other big thing. Our team composition was becoming too heavy on the front-end. So we started exploring the idea of decoupled Drupal with React, in the hopes of redistributing the work while we hire more people. Despite its potential promise, it only added strain to the strugging Drupal team. Setting up a new build system, creating API layers, worrying about security, etc. - these don't come for free.
The early signs of trouble
It all started when a teammate was having a hard time integrating their React work into a Drupal project. The Drupal project in question uses Yarn workspaces and treats every module and theme as a workspace. This allowed us to manage their dependencies and scripts in one place. That means, from the top of the repo, one can just
yarn workspace WORKSPACE_NAME add DEPENDENCY to add a dependency.
Sounds too good to be true right? Indeed it was. When it worked, it's great! But when it failed, it becomes a hair-ripping experience. In this case, the build for the React work was failing. Something to do with dependencies and compilation. I initially brushed it off as yet another local environment issue, because it usually is.
"Please use the Node.js version defined by the project."
"Make sure you have xcode CLI tools installed."
"Please make sure you commit your updated lock file."
But this time, it was much more serious.
The first symptom was Babel spitting out this error:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main resolved in node_modules/@babel/helper-compilation-targets/package.json
After hours of searching and wading through Github issues, I eventually found out that this was caused by having an older version of that package, and bumping it up from 7.8.0 to anything 7.8.3 or higher would fix the issue. Since this package wasn't a direct dependency, I added a
resolutions entry in
package.json. But there was a question nobody had asked: Why was this package working in the main branch, and why is it breaking on this branch?
From there, it only became worse.
[webpack-cli] Unknown argument: --no-progress
This wasn't much of a surprise. The existing Drupal codebase uses Laravel Mix version 5, while the new React work uses version 6 which removed this option. However, this error happens when building the other workspaces, the ones using Laravel Mix 5. So it's possible that Laravel Mix 6 took its place. It turns out that Yarn hoists the version of a conflicting module with the most dependents. In other words, hoisting could change depending on your dependency tree's composition. The behaviour is NOT deterministic.
(It remains unknown why Laravel Mix 6 was chosen for hoisting. Around 8-10 workspaces depended on Laravel Mix 5 and only the React work depended on Laravel Mix 6. Based on this logic, Laravel Mix 5 should have been hoisted. However, in the previous case, it is likely that certain portions Babel dependency tree were bumped up while some remained in the old version thus causing the error.)
To avoid sinking too much time, we decided not to attempt making both codebases play nice with each other and instead isolate them from each other. Yarn has a
nohoist option, a rather convoluted way to isolate a workspace's dependencies. The assumption was that placing the React work in
nohoist would cause its dependencies to stay local in its workspace while everything else would hoist as if the React work didn't exist.
It didn't turn out that way.
One CLI for webpack must be installed. These are recommended choices, delivered as separate packages: - webpack-cli (https://github.com/webpack/webpack-cli) The original webpack full-featured CLI. We will use "yarn" to install the CLI via "yarn add -D". Do you want to install 'webpack-cli' (yes/no):
Several things worked as expected. Laravel Mix 6 remained local to the React workspace. Webpack 5 remained local to Laravel Mix 6 as well. Laravel Mix 5, the version used by the existing codebase, was hoisted to the root of the project as expected. However, Webpack 4 and several Babel modules were not hoisted and out of Laravel Mix 5 and remained local to it. This caused the scripts that were relying on these modules to exist on the module resolution path to just break.
What we did in the end
With time running out, we decided to just omit the React work from the Yarn workspaces setup altogether. This allowed us to continue using the old dependencies and setup for the existing Drupal work while allowing the React work to use whatever dependencies it wants. We also had to adjust our build process to build the old stuff separately from the React work.
We were also at fault here. Workspaces is a monorepo mechanism. The idea of a monorepo is to share dependencies between packages, and have their versions be in lock step with each other to minimize problems. In this scenario, we should only have had one version of Laravel Mix, one version of Webpack, one version of Babel and so on. But of course, we all know that when everything's in crunch mode, things like these get tossed aside.
I also don't expect this being fixed in the near future given the nature of the project and its constraints. But I hope this experience will go into the history books as part of "how not to do things" chapter.
The moral of the story here is to always vet new technologies thoroughly before using it on a project. Also, vet new technololgies on something that is not critical, like an internal project. The other lesson here is to keep your tech stacks simple. While new tech does come with some better developer ergonomics, it doesn't help when you have to maintain a dozen of them, some of which may be at odds with each other.
There is a reason why the enterprise prefer the old and boring. We straddle along tight timelines and budgets. The last thing we need is something behaving or breaking unexpectedly. Don't fix what isn't broken.
Author's note: It's been a long time since I wrote a rant piece. Debugging issues like these is routine work. What's not routine is when you're in perpetual fire-drill mode. Sprints should always have wiggle room for unexpected issues, and time should be set aside to remedy technical debt. Work-life balance and mental health is crucial, especially in these challening times.