@hotwired/turbo: How we upgraded our entire Ruby on Rails monolith in one month
Intro
Over the last 7 months, we have completed several large-scale upgrades to our Rails Monolith in hopes of keeping our platform modern and ready for the next big move. The most daunting of which is undoubtedly the migration from Turbolinks and @rails/ujs to @hotwired/turbo after we moved to Rails 7. Over the course of one month we overhauled nearly every corner of the UI & refactored most of our controller responses to accomplish the migration.
Please make sure to read through what these packages have to offer before continuing in the article as we’ll be focusing primarily on the upgrading process.
Strategy
We started by grouping work in buckets of difficulty. After reading through the turbo documentation we found the following strategy would work well for us:
- Refactor the
method: :delete
toturbo_method: :delete
forlink_to
tags. - Order all .js.erb (UJS) files by length to assess difficulty. With more lines came more reactivity and thus more complexity and refactoring needed. Those working on smaller files should be moving quickly through the list, with the higher complexity files taking longer to complete.
- We then chose to refactor all occurrences of
remote: true
into turbo frames because we wanted to start working with forms. (P.s we really found the form replacement lifecycle to be a great place to start learning about turbo!) - Approach the rest of the files from short to long in small teams until the files remaining hits zero. For reference, we had to replace 177 .js.erb files during our upgrade 🙃.
Our approach to view components
For years we have been loosely adopting Joel Hawksley’s View Components into our app in hopes to eventually remove most of our partials! Although we still have a long way to go, during our migration to turbo we finally refactored hundreds of our partials into view components. To learn more of the benefits we see with moving to components read here.
Main fallout after migrating
Same route being hit by multiple unknown sources
The simplest endpoint is one that has a single response as outlined in Turbo’s handbook. For example, consider an app with a users table.
User form component
Controller “update” action
HTML response
This is a very simple lifecycle of a turbo-frame which is rendered inside a view component. But what if there was somewhere else in our app calling the same endpoint but requires a different response? One way we implemented this over the years (pre-turbo) is to do the following:
Challenge
One of the main issues which we introduced gradually over the years was complex controller return methods (opposing the recommended skinny controller). Some of our controller methods housed multiple responses conditionally rendered based on large amounts of business logic. This was an obvious growing pain of our platform, and we were excited / terrified at the opportunity to detangle them! We determined there were a couple approaches to the issue:
- Leave the refactoring of the controller to another time and rebuild the response partials with turbo-frames as-is.
- Clean up the controller method to have one response turbo_stream and have the conditional logic in the partial.
- Break up the controller method for each response with a dedicated endpoint for each.
We chose to pursue the third option. While it introduces a bit more risk to refactor our app that heavily, we determined the benefit of simplifying our controller interface was worth time. The resulting controller from the previous example would be:
Error handling
We ran into multiple instances where error cases were handled incorrectly as a result of the move which left users confused as to whether their changes were persisted or not. The main items we identified were:
- Forms not properly displaying the error messages. For example, if an endpoint didn’t properly handle a failed update and defaulted to rendering the read-only component instead of responding with the form & errors.
- Toast messages not set on redirects.
- For async queries triggered through stimulus controllers: Uncaught errors leading to console error logs.
Missing form fields/attributes lost during move to component
As I mentioned a large part of our upgrade was also moving a lot of our views into view components. We had a lot of success with this, but we did have one big issue: human error. We were our own worst enemies and missed some fields/UI components during the move to components. This is mostly a friendly reminder to take your time while you’re refactoring your code, it’s easy to miss something! Also note that we have very little end-to-end tests, so we had to do most of the validation manually.
This brings up a good point, due to the lack of end-to-end testing we had to make an elaborate manual tracker to ensure we covered the entirety of the application before the final merge. Whatever stage your testing strategy is at, it’s always prudent to consider and establish a testing strategy prior to beginning implementation. In our case it was manual acceptance testing, how are you planning on doing it?
Upgrading to 7.3.0
This is mostly an announcement for anyone who is running @hotwired/turbo < 7.3.0 and looking to update the minor version. There are a couple intricacies you should be aware of which are outlined in this github thread. This upgrade rendered most of our application unusable without the incorporation of a temporary patch.