When joining a new team, you often inherit a codebase that has been around for a while. It's easy to look elsewhere, ignore the warts, and fix little typos here and there.
I find the best kind of onboarding (if the team allows it), is to look at the long-standing issues and try to fix them. It helps you understand the codebase much better. That's what I did.
The Rails/Webpacker/React crossroad
Last year, I was facing the following issue in a codebase that was running on Rails and React:
- Webpacker was used to bundle the React code. But it was discontinued in Rails 7 without a clear and easy upgrade path to anything
- With Webpacker in limbo, the Node version was stuck on 16.x, with 22.x being the latest LTS
- The tests were written with Enzyme, which was abandoned after 2021
- Enzyme was blocking the React upgrade, as it was incompatible with React 18.x
- The legacy context API was used
- Apollo Client, Jest, and MSW were outdated as well
Essentially, we had two big blockers.
Node was blocked by Webpacker, and React was blocked by Enzyme. Everything else was blocked due to the first two.
Looking for a Webpacker replacement
While the JavaScript ecosystem is often messy, nothing prepared me for this letdown from the Ruby world. I was also slightly annoyed that DHH celebrated moving away from Webpack as a win, while teams were left holding the hot potato.
Anyway, the last Webpacker version was v5
. There was no v6
released except for a release candidate (v6.0.0-rc.6)
.
If you wanted to keep your webpack config, the two options were shakapacker
& jsbundling-rails
.
I initially looked at shakapacker, and the steps I had to take were:
- Upgrade from webpacker
v5
tov6.0.0-rc.6
(handle whatever breaking changes there were) - Migrate from webpacker
v6.0.0-rc.6
to shakapackerv6
(can't upgrade from webpackerv5
directly) - Upgrade to shakapacker
v6.5.2
(there were a few changes needed) - Upgrade to shakapacker
v7
(a lot of breaking changes)
That said, I didn't go into that rabbit hole. Upgrading to v6.0.0-rc.6
was breaking already as we were on a newer Ruby version (v3.3.0
).
bundle exec rails webpacker:install
apply /Users/dnlytras/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/webpacker-6.0.0.rc.6/lib/install/template.rb
identical config/webpacker.yml
identical package.json
Copying webpack core config
exist config/webpack
identical config/webpack/base.js
identical config/webpack/development.js
identical config/webpack/production.js
identical config/webpack/test.js
bin/rails aborted!
NoMethodError: undefined method `exists?' for class Dir (NoMethodError)
if Dir.exists?(Webpacker.config.source_path)
^^^^^^^^
Did you mean? exist?
What a mess. I decided then, that even if I kept the webpack config, I still wouldn't want to maintain that pipeline. As much as I was annoyed with the rug pull, I never liked Webpack.
So, I opted for a move away from Webpack to Vite. I've used Vite before, and I was very happy with it. The only issue here is if there's a good integration with Rails.
I'm not a Ruby developer; this was my first Rails project. I had to learn a lot of things on the go. The team also had created an issue to migrate off Webpacker. So I wasn't being a weird newcomer suggesting to change the whole stack. I just took the opportunity to do it.
Moving from Webpacker to the Vite & Vite-Ruby
To my surprise, I found vite-ruby
that has great integration with Rails, and gives a few pointers on how to migrate from Webpacker.
Eventually, I made it work. I don't want to expand much on the details, but it was mostly:
- Writing a new Vite config
- Moving from Webpacker packs to Vite entrypoints
- Trimming down unused dependencies
- Finding workarounds for some stimulus controllers to work with Vite
- Upgrading node versions in CI and locally, unlocking
fetch
- Enabling fast refresh (no more full page reloads!)
- Dropping most of the Babel plugins, and only keeping what was needed (mostly for Jest)
- Enabled code splitting and lazy loading, reducing the bundle size by 60%. (Could also be done with Webpack, but it wasn't set up)
I hit pause for a few days to let the dust settle and monitor production. By that time, I had pretty much tested every flow in the app - what an onboarding experience! Nothing of note happened, so I label this a great success.
The only thing I missed was enabling emptyOutDir
in the Vite config. As a consequence I would keep the outdated assets around, but that was an easy fix.
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import commonjs from "vite-plugin-commonjs"
import RubyPlugin from "vite-plugin-ruby"
export default defineConfig({
plugins: [
RubyPlugin(),
commonjs(),
react({
babel: {
plugins: [""],
},
}),
],
build: {
emptyOutDir: true, // this was missing
commonjsOptions: {
transformMixedEsModules: true,
},
optimizeDeps: {
include: [""],
},
},
})
Moving from Enzyme to React Testing Library
With hot-reload working, it was time to work on the React side of things. My team was using Enzyme for testing, but had already started writing new tests with React Testing Library (RTL).
As you might imagine, the migration process is very frustrating. Enzyme tests the implementation details, while React Testing Library tests the user experience, simulating normal user actions.
So let's say you're testing a chart component. Enzyme would check if the props are passed correctly, and if the lifecycle methods are called. You can change all props at will and simulate any scenario. With that out of the way, you face the harsh truth that your tests might have blind spots. To test the same component with RTL, you have to manually hover over the lines and bars, check the tooltips, axis calculations, and so on. Essentially you rewrite the whole test, there's zero overlap.
I started migrating a few tests per day. This unearthed a few bugs in the codebase (mostly due to the shallow rendering of Enzyme), but it gave me confidence that we actually test the right things.
It wasn't hard per se, but more of a tedious task that someone had to tackle consistently bit by bit. I also started experimenting with LLMs at that time, but I found that currently available models couldn't provide meaningful assistance for this task. Pain.
Upgrading React to v18
With Enzyme gone, the React upgrade was unblocked.
To my dismay though, I found a blocker for React 19. The PropTypes were discontinued. Moving to TypeScript is not an option for everyone, especially when your team writes the business logic in a dynamic language like Ruby. So I feel like React is dropping the ball here.
Anyway, that's a problem for the future, let's move on to React 18 first. Some of the changes I made was:
- Dropping the legacy context API
- Debugging some issues (hint: there were
useEffect
hooks returningnull
instead ofundefined
) - Rewriting a few critical class components that were using
UNSAFE_componentWillMount
- Removing
defaultProps
Overall the process wasn't hard, it only had been delayed for a long time. Did we gain anything? I assume automatic batching, but I didn't see any noticeable performance improvements.
Upgrading Jest & React Testing Library
Lastly, I made a big version bump from v12
to v16
in React Testing Library. The details are kinda hazy right now, but the biggest changes were dropping the @testing-library/react-hook
package, fixing some race conditions, and adding a few waitFor
calls.
As for Jest, I took a jab at removing it in favour of Vitest, but I didn't have the energy. I just wanted to move on. Instead I decided to upgrade Jest from v27
to v29
. Nothing much changed, I removed the resize-observer-polyfill
, fixed a few issues with the snapshots, and had to install jest-environment-jsdom
separately for my troubles.
I finished the refactor happy with the Vite migration, but the rest felt like a chore to be done. I didn't feel like I was getting anything out of it, and I was just upgrading stuff for the sake of it.
Other changes happening in the background
While I was doing the above, my team continued with:
- Migrating from Yarn v1 to NPM
- Upgrading Storybook
- Upgrading Eslint (still don't get what flat config offers to us)
- Class components to function components refactors
- Normal work that actually makes money
I was also doing normal work. My favorite feature was building a new charts library (that came with a D3 major bump upgrade as well).
I hadn't worked with D3 that intensely before, so I had to find a nice way to tie that with React, and implement features like zooming, comparing charts, toggling outliers, applying themes, locking, maintaining performance for big datasets, and so on.
I also worked on the Rails side of things, mumbling under my breath "OOP isn't real, OOP won't hurt you".
MSW and Apollo Client
The refactors took a pause, and a good deal of months passed. Recently I went back to the drawing board and picked two leftovers.
- Upgrading MSW from
v0
tov1
- Upgrading Apollo Client from
v2
tov3
The MSW upgrade was very tricky and the kind of refactors I hate the most. It's package that only serves purpose for development purposes, has breaking changes, and you don't really care to upgrade. I wasn't even sure if I should do it, but there were a few flakey tests related to MSW's setup, that were bothering me. Thankfully, LLMs were a much better help this time around. I also have the v2
version to upgrade to, but I'll get to that some other time.
After that, I focused on the Apollo Client. The biggest issue (and I'm glad for this change), was that the data became immutable/frozen. There were some runaway mutations happening in the codebase, and I was delighted to catch them.
Future improvements
I'm happy with the state of the codebase now. There are no dependencies that can block us from building features, and we can move forward with confidence. The only real thorn is how to upgrade to React 19.
- Do we move to TypeScript? I don't think so. I want to minimize the front-end logic, and simplify things.
- Do we drop prop-types and lose the implicit documentation that comes with it? It feels like going backwards. I don't want to have outdated JSDocs instead.
- Is the new RSC direction of React something that interests us? No, we use Rails. I'm excited to try it in Tanstack Start or React Router 7, but in the context of Rails it's absolutely useless.
This is something that I haven't come to terms yet. Not sure what to do.
Other than that, my only other goal is to re-evaluate Vitest and its browser-mode feature. I feel we can make tests faster, drop the leftover babel plugins, and simplify our tests without mocking stuff like getComputedStyle
.
Final thoughts
As I proofread this, I can't help to think that a good chunk of this work could have been avoided if we didn't combine React with Rails. Webpacker really slowed us down, and the unexpected retirement made things even worse.
I can't speak about Turbo, Hotwire or Stimulus, as I haven't developed with them. My only experience is with using applications built with them that feel sluggish and slow. I don't know if it's the framework, or the implementation, but I don't like it.
If I were to suggest a different approach, I would pick Inertia and completely drop Apollo as well. Of course Inertia wasn't as mature or widely known back then, but if anyone is considering between their in-house solution or shipping two apps, put Inertia on the table as well. Here are the docs for Rails and a great introduction by Evil Martians.
As for the never ending stream of dependency upgrades on the Javascript front, I don't mind it that much. I usually run npx check-updates -i
and handle them every week. It only becomes a problem that compounds over time if not addressed regularly. That said, I still can't shake the feeling that we, developers, waste time on unnecessary upgrades like Eslint and Storybook.
I hope this was a fun read. If you find yourself in the same situation regarding React 19 and PropTypes, feel free to reach out. I would love to hear your thoughts on it.
Resources
- Modern web apps without JavaScript bundling or transpiling (DHH)
- Upgrading from Webpacker v5 to Shakapacker v6
- Switch from Webpacker 5 to jsbundling-rails with webpack
- Vite Ruby
- Enzyme is dead, now what
- Migrate from Enzyme
- How to Upgrade to React 18
- React 19 Upgrade Guide
- Inertia.js in Rails: a new era of effortless integration