Turbolinks & DOM events
DOMContentLoaded event (or any library functions that rely on it, like
jQuery.ready) won’t be triggered on subsequent page loads, resulting in your pages not getting initialized properly.
Turbolinks has a bunch of additional events to help with the AJAX-based page load cycle. The most important ones are
page:before-change. They are both available on the
page:change event is triggered both when the page has been parsed and changed to the new version as well as on
page:before-change event is triggered immediately after a Turbolinks-enabled link has been clicked.
Aside from the page load lifecycle, there are a few other smaller issues. Since the browser doesn’t get to start from scratch on each page load, memory leaks and event listener management become potential problems. With Turbolinks, we need to be a bit more careful about what’s happening.
Bacon.js to the rescue!
In a nutshell, Bacon.js is all about streams of events. Event streams are a lot more powerful than just plain events, since they can be mutated, combined, filtered, etc. Streams can also be subscribed to, which is essentially the same as adding an event listener:
In both cases the
doSomething handler gets called on every click on
.myElement with the event object as a parameter.
The big difference between using Bacon.js streams and plain jQuery is that with Bacon.js we can use functional reactive programming patterns and create event streams that are automatically cleaned up after a certain event:
# Create a stream of page:before-change events and
# map every event to true. This means the listener
# function will get `true` as a parameter every time.
beforeChange = $(document).asEventStream("page:before-change").map(true)
# Create a stream of all page:change events and
# map every event to false.
pageLoad = $(document).asEventStream("page:change").map(false)
# Initialize sidebar toggling on every page load.
So, for every event in the
pageLoad stream (i.e. on
page:change events), create a stream of click events on the
.openSidebar element and listen to them only until the next event in the
If you’re wondering why we map the values to booleans, we’ll get back to that later.
The equivalent code using plain jQuery would be:
$(document).once 'page:before-change', ->
Now you’re probably thinking “How’s this supposed to be better and easier?” or “I could just use live selectors on document!”. First, Bacon.js streams created with
.takeUntil are cleaned up automatically and are truly fire-and-forget: you really don’t need to care about unbinding things manually. Second, binding every possible listener to document and using event delegation or live selectors is guaranteed to hinder performance (at least if you have things like scroll listeners or other performance intensive event listeners). You’re also going to run into cases where a stale event handler that was meant for a previous page starts messing with your current page.
Next, some more complex issues that are just plain easy to solve with Bacon.js.
Let’s get back to why we added the boolean mappings to the event streams in the previous example. Unfortunately, when you use Turbolinks, the AJAX calls won’t spin the browser’s loading indicator. What can we do when a user with high network latency clicks a link and nothing seems to happen?
Using the Bacon.js streams from the previous examples, we can easily add a loading indicator to the page. We’re going to use another type of observable from Bacon.js:
Property. A property is like an event stream, except it has a concept of current state.
So, to create a property that knows when the page is loading, we need to combine the event streams beforeChange (which emits
true values) and pageChange (which emits
false values). A property can be created from an event stream with
.toProperty(initialValue), which also specifies what the initial value is (
false in our case).
What follows then is a bit of Bacon.js wizardry. We want to add the loading indicator to the page only if the request takes more than 200ms. Fast page loads should seem instant, and they should not flash the loading indicator. We take the
loading stream that emits
true when the new page request starts and
false when the new page is completed and add 200ms of delay. Because we want to know if the page is still loading after 200ms, we do a logical
and operation with the original undelayed stream, and finally use Bacon.js’s
assign, which basically ends up calling
$("html").toggleClass("loading", event) with each boolean event.
This example nicely illustrates, how using Bacon.js streams and properties frees us from the need to manage state manually, and instead lets us concentrate on how the state should change.