Engineering

The Path to Rails 8.1: Moving to Turbo, One Page at a Time

Dallas Read's profile picture Dallas Read on

When we built the DNSimple app, Rails didn't have an answer for everything a modern front end needs, so we picked the best tools available at the time and filled the gaps ourselves. That has changed. With Rails 8.1, the framework comes with a clear, well-supported way to handle interactivity, JavaScript and assets, and it matches where we want to take our design and tooling.

So we're moving our codebase onto standard, well-documented tools, so that anyone working in it, human or agent, can treat it like any other Rails app.

We're doing it one piece at a time: Turbo instead of rails-ujs, Stimulus instead of our own Vue mounting layer, Tailwind instead of Bootstrap and Tachyons, importmaps instead of Vite.

We're sharing the journey in a series of blog posts, so you can learn from what we did, including the parts that surprised us.

This first post is about one of those surprises. Our old JavaScript was quietly doing more than we thought, and we only noticed when we took it away. Here's how we found those hidden side effects and what we did about them.

Where we started

One Rails codebase holds our three subapps: app, admin, and site. All three used rails-ujs for links, forms, and confirmation dialogs. They used Bootstrap and jQuery for most of the rest. Pages that needed more than server-rendered HTML used Vue, and a few pages used native web components. Each piece made sense when we added it. After 15 years, those good decisions had become legacy code and technical debt, and they slowed down every change we made.

Take our Vue mounting script. After an old injection vulnerability, we stopped letting Vue control a whole page. Instead, the script wraps each component in its own <div>:

const wrapElement = (el) => {
  const wrapper = document.createElement('div');

  wrapper.className = wrapperClass(el);

  el.parentNode.insertBefore(wrapper, el.nextSibling);
  wrapper.appendChild(el);

  return wrapper;
};

The code did its job. But each bespoke piece was one more non-standard thing that a new developer, or agent, had to learn from us.

What Turbo Drive exposed

Turbo Drive doesn't reload the page. It fetches the next page and swaps the <body>. That means it has to care about things a full reload ignores, like the status code of a response, or whether a GET is safe to send early. Our code had never had to deal with either.

The Vue mounting script was the first thing to change. Components now mount after each Turbo visit and unmount before Turbo caches the page. Otherwise, a page from the cache shows static HTML that doesn't respond.

// Mount on the initial load and after every Turbo visit (Turbo swaps the
// <body>, so components such as the navbar account switcher must re-mount).
// Unmount and restore the original markup before Turbo caches a snapshot so
// restored pages re-mount cleanly instead of showing dead, static HTML.
onReady(mountComponents);
document.addEventListener('turbo:before-cache', unmountComponents);

Rolling it out

We enabled Turbo Drive in the JavaScript entrypoint. Then we added a Ruby helper that turns it on for one controller at a time:

def self.enable_turbo_drive(**)
  before_action(**) { @turbo_drive_enabled = true }
end

If a controller doesn't call enable_turbo_drive, its pages work as before. rails-ujs drives them with full reloads and native window.confirm. The two libraries run side by side until each page is ready. On a migrated page, links open without a white flash, and destructive actions confirm in a styled dialog instead of the browser's window.confirm popup.

When most pages used Turbo Drive, we made it the default. The helper became an opt-out:

def self.disable_turbo_drive(**)
  before_action(**) { @turbo_drive_disabled = true }
end

Now only a short list of pages calls disable_turbo_drive:

  • The billing pages. Stripe Elements must mount on a real full-page load, and Turbo must not cache the payment iframe.
  • The login page. When a Turbo Frame request finds an expired session, the browser must do a full-page navigation, not a frame swap.
  • The admin invoice download. It streams a PDF, and Turbo can't swap or follow a PDF.

Forms that did nothing

Our controllers re-rendered invalid forms with a 200 status, which is what Rails scaffolds did until Rails 7. A full-page load shows whatever HTML comes back, so the status didn't matter. Turbo only acts on a redirect or an error status, so it ignored our 200. The user clicked Submit and nothing happened. There was no error in the browser and nothing in the logs.

We fixed it with status codes. Invalid forms now re-render with a 422 status, and error pages from a before_action send their own status, such as 400 or 403.

We found it on the first few pages we moved. Because we moved a few pages at a time, only a few forms broke.

The Export button on the domain names page was a plain link to a GET route. That route queued a job that emailed the domain list to the user. Something that sends an email should be a POST, but the link worked fine for years.

Then Turbo 8 started to prefetch GET links on hover. A tenth of a second with the cursor on the Export button sent the request, which sent the email. Turbo throws away a prefetch response, so the screen didn't change. A second hover sent a second email.

Unfortunately, our test suite enforced the bug, not the intended behavior. The request spec sent a GET and checked that the export was queued, so it passed.

We made the route POST-only:

  resources :domain_names, only: [:index] do
    collection do
-     get "export"
+     post "export"
    end
  end

Then we changed the Export link to a button that submits a form:

- button_link_to "Export", export_domain_names_path(this_account)
+ button_to "Export", export_domain_names_path(this_account), method: :post

We could have turned off prefetch for that one link instead, but then we would still have a GET that sends email.

Next in the series

Neither side effect was in code we had touched. Both had worked for years under full-page reloads, and one even had a passing test. Each one we tracked down left the codebase a little easier to understand, for us and for the agents working alongside us.

If you're planning the same move, our advice is to expect surprises and go one piece at a time. Small steps make it much easier to see what broke and why.

Next up, replacing jQuery and our Vue components with Stimulus. Our pre-flight DNS resolution pop-up went in as a 350-line Vue component and came out as a Turbo Frame and a small Stimulus controller. How do you delete that much code and lose nothing? We'll show you in the next post.

Working on a similar migration and have questions? Get in touch. We're happy to share what we learned.

Share on Twitter and Facebook

Dallas Read's profile picture

Dallas Read

Dream. Risk. Win. Repeat.

We think domain management should be easy.
That's why we continue building DNSimple.

Try us free for 30 days
4.5 stars

4.5 out of 5 stars.

Based on Trustpilot.com and G2.com reviews.