After upgrading to Turbolinks 5 we started seeing duplicate plugins when pressing the browser back button.

This is because our plugin (ie select2 and datatables) is being initialised for a second time and is explained with:

Often you’ll want to perform client-side transformations to HTML received from the server. You may have a JavaScript function that queries the document for all select inputs and initialises select2. If this runs on turbolinks:load (ie when using jquery-turbolinks with the Turbolinks compatibility shim), when you navigate away, Turbolinks saves a copy of the transformed page to its cache. When we press the Back button, Turbolinks restores the page from cache, fires turbolinks:load again, and our code inserts a second set of the plugin.

To avoid this problem, make your transformation function idempotent. An idempotent transformation is safe to apply multiple times without changing the result beyond its initial application

The above is modified from

For example, with select2, this could be fixed with:

<span class="hljs-keyword">if</span> (!$(<span class="hljs-string">'select'</span>).hasClass(<span class="hljs-string">'select2-hidden-accessible'</span>))
  $(<span class="hljs-string">'select'</span>).select2({...})

So it’s all happy now? Not so fast….

I think it’s worthwhile reinstating:

Turbolinks saves a copy of the transformed page to its cache

So what is the transformed page? I believe it’s the DOM elements only and not the events associated with the elements. This leads us to the next problem we run into. We’ve fixed our code so it’s idempotent and duplicates are no longer experienced. However, our plugin events no longer fire. This is explained here:

Turbolinks saves a copy of the current page to its cache just before rendering a new page. Note that Turbolinks copies the page using cloneNode(true), which means any attached event listeners and associated data are discarded.

I mistakenly thought Persisting Elements Across Page Loads would fix this:

Turbolinks allows you to mark certain elements as permanent. Permanent elements persist across page loads, so that any changes you make to those elements do not need to be reapplied after navigation. Designate permanent elements by giving them an HTML id and annotating them with data-turbolinks-permanent. Before each render, Turbolinks matches all permanent elements by id and transfers them from the original page to the new page, preserving their data and event listeners.

This did not fix the problem of events not firing.

The easy but possibly unsuitable fixes

  1. Ensuring Specific Pages Trigger a Full Reload


  1. Turbolinks.clearCache


  1. Opting Out of Caching


Why not use the easy fixes?

In our app some of our jQuery plugins are used frequently (ie select2 and datatables) over multiple views. The easiest fix would have been to opt out of caching completely but then we would obviously lose the speed benefits of caching:

Turbolinks maintains a cache of recently visited pages. This cache serves two purposes: to display pages without accessing the network during restoration visits, and to improve perceived performance by showing temporary previews during application visits.

I read that you can opt out of caching within the <body> tag (rather than in <head>) and use logic based on the controller and action to determine whether to cache or not, however, as mentioned, we use the plugins frequently and I didn’t think this would be a clean and maintainable solution.

This is likewise for maintaining what actions need to use Turbolinks.clearCache()

  1. Preparing the Page to be Cached


We can unbind the plugin (select2 in this example) before it’s cached:


  1. Using Stimulus
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span> </span>{
  connect() {
    <span class="hljs-keyword">this</span>.select2mount()
    <span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">"turbolinks:before-cache"</span>, () => {
      <span class="hljs-keyword">this</span>.select2unmount()
     }, { <span class="hljs-attr">once</span>: <span class="hljs-literal">true</span> })

    select2unmount() {
      $(<span class="hljs-keyword">this</span>.element).select2(<span class="hljs-string">'destroy'</span>)

On connect() of our Stimulus controller (which is called even when navigating to a cached page), before we cache the page we tear down the select2 plugin. For this instance we are storing state in the database so we don’t need to store the select values for the cache, however, if that was the case you could use:

<span class="hljs-comment">// make sure the HTML itself has those elements selected</span>
<span class="hljs-comment">// since the HTML is what is saved in the turbolinks snapshot</span>
values.forEach(<span class="hljs-function">(<span class="hljs-params">val</span>) =></span> {
  $(<span class="hljs-keyword">this</span>.selectTarget).find(<span class="hljs-string">`option[value="<span class="hljs-subst">${val}</span>"]`</span>).attr(<span class="hljs-string">'selected'</span>, <span class="hljs-string">'selected'</span>);

On initialisation Datatables may insert new DOM elements ie the Search input. Before we leave the page we tear down the plugin to avoid re-initialising it twice. With Datatables, I believe this should also remove all of the newly added DOM elements so we’re back at the original state of the table that we’ve coded in our views. There is a caveat to this though, with server side datatables populated by AJAX, the tbody maintains it’s data even though that wasn’t present in our original code. Plus there’s different behaviour depending on whether you’re doing an application visit versus a restoration visit which added to the confusion.

To disable cache or to not disable cache?

In the future, if Turbolinks with a jQuery plugin is giving us grief I would be highly tempted to save time and disable caching for that page….. if it’s not too complicated to maintain This is how Basecamp (in 2016 at least) disabled cache for new and edit pages


Helpful notes

When possible, avoid using the turbolinks:load event to add other event listeners directly to elements on the page body. Instead, consider using event delegation to register event listeners once on document or window

State is stored in the HTML, so that controllers can be discarded between page changes, but still reinitialize as they were when the cached HTML appears again.