How We Migrated To Turbolinks Without Breaking Javascript

In this article, I'm going to tell you about our migration from PJAX to Turbolinks. The good news is that Turbolinks works surprisingly well out-of-the-box. The only tricky thing about it is making it work with your JavaScript. By the end of this article I hope you'll have a good idea of how to do that.

It's 2019, so we decided it was time to take a more modern approach to the Honeybadger front end. We implemented Turbolinks! This is only the first step on an ambitious roadmap. In 2025 we plan to migrate to Angular 1, and we'll finish out the decade on React unless we run into any roadblocks!

But let's get real. Honeybadger isn't a single page app, and it probably won't ever be. SPAs just don't make sense for our technical requirements. Take a look:

  • Our app is mostly about displaying pages of static information.
  • We crunch a lot of data to generate a single error report page.
  • We have a very small team of four developers, and so we want to keep our codebase as small and simple as possible.

The Days of PJAX

There's an approach we've been using for years that lets us have our cake and eat it too. It's called PJAX, and its big idea is that you can get SPA-like speed without all the Javascript. When a user clicks a link, the PJAX library intercepts it, fetches the page and updates the DOM with the new HTML.

It's not perfect, but it works better than you'd think -- especially for an app like ours. The only problem is that our PJAX library is no longer maintained and was preventing us from updating jQuery (ugh). So it had to go.

Moving to Turbolinks

Now if you think about it, PJAX sounds a lot like Turbolinks. They both use JS to fetch server-rendered HTML and put it into the DOM. They both do caching and manage the forward and back buttons. It's almost as if the Rails team took a technique developed elsewhere and just rebranded it.

Well, I'm glad they did, because Turbolinks is a much better piece of software than jquery-pjax ever was. It's actively maintained and doesn't require jQuery at all! So we're one step closer to our dream of ditching $.

In this article, I'm going to tell you about our migration from PJAX to Turbolinks. The good news is that Turbolinks works surprisingly well out-of-the-box. The only tricky thing about it is making it work with your JavaScript. By the end of this article I hope you'll have a good idea of how to do that.

Turbolinks is a Single-Page Application

Turbolinks doesn't just give you some of the benefits of a single-page app. Turbolinks is a single page app. Think about it:

  1. When someone visits your site, you serve them some HTML and Javascript.
  2. The JavaScript takes over and manages all subsequent changes to the DOM.

If that's not a single-page app, I don't know what is.

Now let me ask you, do you write JS for a single page application differently from a "traditional" web application? I sure hope you do! In a "traditional" application, you can get away with being sloppy because every time the user navigates to a new page, their browser destroys the DOM and the JavaScript context. SPAs, though, require a more thoughtful approach.

An Approach to JS that works

If you've been around for a while you probably remember writing code that looked something like this:

    $(document).ready(function() {
      $("#mytable").tableSorter();
    });

It uses jQuery to initialize a table-sorting plugin whenever the document finishes loading. Let me ask you: where's the code that unloads the table-sorter plugin when the page unloads?

There isn't any. There didn't need to be back in the day because the browser handled the cleanup. However, in a single-page application like Turbolinks, the browser doesn't handle it. You, the developer, have to manage initialization and cleanup of your JavaScript behaviors.

When people try to port traditional web apps to Turbolinks, they often run into problems because their JS never cleans up after itself.

All Turbolinks-friendly JavaScript needs to:

  1. Initialize itself when a page is displayed
  2. Clean up after itself before Turbolinks navigates to a new page.

For new projects, I would recommend using Webpack, along with perhaps a lightweight framework like Stimulus.

Capturing Events

Turbolinks provides its own events that you can capture to set up and tear down your JavaScript. Let's start with the tear-down:

    document.addEventListener('turbolinks:before-render', () => {
      Components.unloadAll(); 
    });

The turbolinks:before-render event fires before each pageview except the very first one. That's perfect because on the first pageview there's nothing to tear down.

The events for initialization are a little more complicated. We want our event handler to runs:

  1. On the initial page load
  2. On any subsequent visit to a new page

Here's how we capture those events:

    // Called once after the initial page has loaded
    document.addEventListener(
      'turbolinks:load',
      () => Components.loadAll(),
      {
        once: true,
      },
    );

    // Called after every non-initial page load
    document.addEventListener('turbolinks:render', () =>
      Components.loadAll(),
    );

No, you're not crazy. This code seems a little too complicated for what it does. You'd think there would be an event that fires after any page is loaded regardless of the mechanism that loaded it. However, as far as I can tell, there's not.

Loving and hating the cache

One reason Turbolinks sites seem faster than traditional web apps is because of its cache. However, the cache can be a source of great frustration. Many of the edge cases we're going to discuss involve the cache in some way.

For now, all you need to know is:

  1. Turbolinks caches pages immediately before navigating away from them.
  2. When the user clicks the "Back" button, Turbolinks fetches the previous page from the cache and displays it.
  3. When the user clicks a link to a page they've already visited, the cached version displays immediately. The page is also loaded from the server and displayed a short time later.

Clear the Cache Often

Whenever your front-end persists anything, you should probably clear the cache. A straightforward way to cover a lot of these cases is to clear the cache whenever the front-end makes a POST request.

In our case, 90% of these requests originate from Rails' UJS library. So we added the following event handler:

    $(document).on('ajax:before', '[data-remote]', () => {
      Turbolinks.clearCache();
    });

Don't Expect a Clean DOM

Turbolinks caches pages right before you navigate away from them. That's probably after your JavaScript has manipulated the DOM.

Imagine that you have a dropdown menu in its "open" state. If the user navigates away from the page and then comes back, the menu is still "open," but the JavaScript that opened it might be gone.

This means that you have to either:

  • Write your JS so that it's unfazed by encountering the DOM elements it manipulates in an unclean state.
  • When your component is "unloaded" make sure to return the DOM to an appropriate state.

These requirements are easy to meet in your JavaScript. However, they can be harder to meet with third-party libraries. For example, Bootstrap's modals break if Turbolinks caches them in their "open" state.

We can work around the modal problem, by manually tidying the DOM before the page is cached. Below, we remove any open bootstrap modals from the DOM.

    document.addEventListener('turbolinks:before-cache', () => {
      // Manually tear down bootstrap modals before caching. If turbolinks
      // caches the modal then tries to restore it, it breaks bootstrap's JS.
      // We can't just use bootstrap's `modal('close')` method because it is async.
      // Turbolinks will cache the page before it finishes running.
      if (document.body.classList.contains('modal-open')) {
        $('.modal')
          .hide()
          .removeAttr('aria-modal')
          .attr('aria-hidden', 'true');
        $('.modal-backdrop').remove();
        $('body').removeClass('modal-open');
      }
    });

Remove all Javascript from the body

Turbolinks runs any javascript it encounters in the body of your HTML. This behavior may sound useful, but it's an invitation to disaster.

In "traditional" web apps, scripts placed in the body run precisely once. However, in Turbolinks, it could be run any number of times. It runs every time your user views that page.

  • Do you have a third-party chat widget that injects a <script> tag into the page? Be prepared to get 10, 50, 100 script tags injected.
  • Do you set up an event handler? Be prepared to get 100 of them and have them stay active when you leave the page.
  • Do you track page views with Google Analytics? Be prepared to have two page views registered each time the user visits a cached paged. Why? Turbolinks first displays a cached version, then immediately displays a server-rendered version of the page. So for one "pageview," your page's inline JS runs twice.

The problem isn't just inline JavaScript. It's any JavaScript placed in the document's body, even when loaded as an external file.

So do yourself a favor and keep all JavaScript in the document's head, where it belongs.

Use JS Modules to Load Third-Party Widgets

If you can't use inline JS to load your third-party widgets, how can you do so? Many, such as our own honeybadger-js library provide npm packages that can be used to import them to webpack or another build tool. You can then import them and configure them in JS.

    // Here's how you can set up honeybadger-js inside webpack.
    // Because the webpack output is included in the document head, this 
    // will only be run once. 

    import Honeybadger from 'honeybadger-js';

    const config = $.parseJSON($("meta[name=i-honeybadger-js]").attr('content'));

    Honeybadger.configure({
      api_key: this.config.key,
      host: this.config.host,
      environment: this.config.environment,
      revision: this.config.revision,
    });

There are lots of ways you could pass data like API keys from the server. We encode them as JSON and put them in a meta tag that is present on every page.

    %meta{name: "i-honeybadger-js", content: honeybadger_configuration_as_json}

Sadly, some third-party services don't provide npm packages. Instead, they make you add a <script> tag to your HTML. For those, we wrote a JS wrapper that injects the script into the dom and configures it.

Here's an example of how we wrap the heroku widget for users who purchase our service as a Heroku add-on.

    class Heroku extends Components.Base {
      // For every page load, see if heroku's JS is loaded. If not, load it.
      // If so, reinitialize it to work with the reloaded page. 
      initialize() {
        this.config = $.parseJSON(this.$el.attr('content'));
        if (this.herokuIsLoaded()) {
          this.initHeroku();
        } else {
          this.loadHeroku();
        }
      }

      herokuIsLoaded() {
        return !!window.Boomerang;
      }

      initHeroku() {
        window.Boomerang.init({ app: this.config.app, addon: 'honeybadger' });
      }

      loadHeroku() {
        const script = document.createElement('script');
        script.type = 'text/javascript';
        script.async = true;
        script.onload = () => this.initHeroku();
        script.src =
          '<https://s3.amazonaws.com/assets.heroku.com/boomerang/boomerang.js>';
        document.getElementsByTagName('head')[0].appendChild(script);
      }
    }

    Components.collection.register({
      selector: 'meta[name=i-heroku]',
      klass: Heroku,
    });

Handle Asset Updates Gracefully

Since Turbolinks is a single page application, active users may still be using an old copy of your JS and CSS after you deploy. If they request a page that depends on the new assets, you're in trouble.

Fortunately, you can tell Turbolinks to watch for changes in asset file names, and do a hard reload whenever they change. This approach works well in Rails because your application CSS and JS typically have a content hash appended to their filenames.

To enable this feature, we need to set the data-turbolinks-track attribute on the appropriate <style> and <link> tags. With rails/webpacker, it looks like this:

    = stylesheet_pack_tag "application", "data-turbolinks-track": "reload"
    = javascript_pack_tag 'application', "data-turbolinks-track": "reload"

Give to Turbolinks what belongs to Turbolinks

Finally, realize that using Turbolinks involves giving up control of some things.

  • You can't manipulate the window location in any way using JS without breaking Turbolinks. We had been saving the currently-selected tab state in the URL hash but had to get rid of it.
  • Using jquery to fake clicks on links doesn't work. Instead, you should manually invoke Turbolinks.visit.

Conclusion

I'm a fan of Turbolinks. We've discussed many edge cases here, but for the most part, it works very well out of the box.

PJAX touched nearly every part of our front-end. Replacing something that central was never going to be painless. However, I have to say the migration went much more smoothly than I ever expected.

We've been running it in production for several weeks now and have only had two minor bug reports. For the most part, it seems like nobody noticed the switch, which is my ideal outcome.

author photo

Starr Horne

Starr Horne is a Rubyist and Chief Javascripter at Honeybadger.io. When he's not neck-deep in other people's bugs, he enjoys making furniture with traditional hand-tools, reading history and brewing beer in his garage in Seattle.

“We’ve looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.”
Michael Smith
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you’re paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Error Monitoring Free for 15 Days
"Wow — Customers are blown away that I email them so quickly after an error."
Chris Patton
Try Error Monitoring Free for 15 Days