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:
- When someone visits your site, you serve them some HTML and Javascript.
- 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:
- Initialize itself when a page is displayed
- 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:
- On the initial page load
- 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:
- Turbolinks caches pages immediately before navigating away from them.
- When the user clicks the "Back" button, Turbolinks fetches the previous page from the cache and displays it.
- 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.
Top comments (2)
Very useful article. Do you have any advice about how to approach including page specific JavaScript? I was surprised by how little I could find when searching on this topic. I'm looking at using Webpacker, Turbolinks and Stimulus with Rails. Thanks!
Thanks for this article, Turbolinks is really lacking comprehensive advanced articles and you really did an excellent job at it.
And I really love your trick for clearing cache for all data-remote ππππ.
One thing that I found really useful also is the lifecycle of Stimulus controllers. JS Animations are usually to be done in initialize() to avoid to play them twice when the page reloads after the cache is first displayed