Rails Turbolinks 5 : How do I run JS code on a particular page enter and exit?

1.6k views Asked by At

I'm playing with Turbolinks 5 and Rails, trying to work out how it changes the game.

Traditionally my pages work by having a bundle of javascript which contains libraries (jQuery, Knockout, etc) and application specific code, e.g. view models - all attached to the window object in some organised structure. All controllers in an area of the application, e.g. the frontend, will share the same JavaScript (and CSS) bundle.

On each page that uses JavaScript, there will be an initializer of sorts to get things started. For example:

$.ready(function() { window.users.update.init(#{@user_edit_view_model.javascript_configuration.to_json.html_safe}) } )

The responsibility of this init function would then be to set up a knockout view model and bind it to some node. The javascript_configuration probably would contain the initialization state of the current user.

Anyway, this approach doesn't seem to play well with Turbolinks at all.

I understand that it will only fire if the user accesses users#edit as the first page (or does a hard refresh), so $.ready is obviously out of the picture.

If I attach to the turbolinks:load event, the above code fires not just when the user enters users#edit, but also on any page the user navigates to subsequently (until he/she does a full refresh at some point).

I have been able to get around this by defining a function that removes the callback after the first execution.

# For running code after the current page is ready
window.turbolinks_in = (callback) ->
  event_listener = ->
    callback()
    window.removeEventListener("turbolinks:load", event_listener)

  window.addEventListener "turbolinks:load", event_listener

In addition, I have also devised:

# For running code when leaving the current page
window.turbolinks_out = (callback) ->
  event_listener = ->
    callback()
    window.removeEventListener("turbolinks:before-cache", event_listener)

  window.addEventListener "turbolinks:before-cache", event_listener

These two functions seemingly allow me to run code when a page loads and when it "unloads".

My questions are:

  1. Seeing as I had to come up with my own wrapper functions, I suspect Turbolinks has been deliberately developed to discourage the initialization flow that I use. Is that true?
  2. If it's true, what is the idiomatic way to go about? How should I set up a knockout view model or for that matter, run any code that pertains to the particular page?
  3. If it's not true, how do I reliably set up an unload/leave function for a page. turbolinks:before-cache is not emitted when the cached page is replaced with a fresh page, so how do I cleanup after the brief display of the cached page? I do understand that it semantically makes sense that turbolinks:before-cache should not be fired when the (already) cached page is replaced, but what goes in it's place then? Something like turbolinks:unload which doesn't exist.
1

There are 1 answers

0
Dom Christie On
  1. Seeing as I had to come up with my own wrapper functions, I suspect Turbolinks has been deliberately developed to discourage the initialization flow that I use. Is that true?
  2. If it's true, what is the idiomatic way to go about? How should I set up a knockout view model or for that matter, run any code that pertains to the particular page?

In terms of running code on a particular page, first of all, I'd generally avoid adding an inline script for that page. This normally works OK in non-Turbolinks apps, but in Turbolinks-enabled apps, where state and event handlers are maintained between page loads, things can get a bit messy. Ideally you should contain all your JS in a single file. (If you're wondering about how to inject server-generated content, I'll cover that a bit later.)

You're on the right lines in terms of setting up and tearing down, but I wonder if, rather than your turbolinks_in and turbolinks_out functions, you'd consider implementing a single initialize function for setup on turbolinks:load, and subsequently a single teardown function on turbolinks:before-cache and on turbolinks:before-render (this is the "unload" event you're after). Your setup/teardown code might look like:

document.addEventListener('turbolinks:load', window.MyApp.init)
document.addEventListener('turbolinks:before-cache', window.MyApp.destroy)
document.addEventListener('turbolinks:before-render', window.MyApp.destroy)

So rather than directly calling an object's init function in an inline script, window.MyApp.init decides which objects to initialize.

How does it decide?!

It seems like you're mirroring controller names and action names for your JS objects. This is a good starting point, we just need to get those names into the client side. One common approach (used by Basecamp, I believe) is to use meta elements in the head to output some server-generated attributes:

<meta name="controller_name" content="<%= controller_name %>">
<meta name="action_name" content="<%= action_name %>">

As long as your objects follow the controller/action naming pattern, your app's initialize function might be something like:

;(function () {
  window.MyApp = {
    init: function () {
      var controller = window[getControllerName()]
      var action
      if (controller) action = controller[getActionName()]
      if (action && typeof action.init === 'function') action.init()
    }
  }

  function getControllerName () {
    return getMeta('controller_name')
  }

  function getActionName () {
    return getMeta('action_name')
  }

  function getMeta (name) {
    var meta = document.querySelector('[name=' + name + ']')
    if (meta) return meta.getAttribute('content')
  }
})()

You could follow this approach to include your JavaScript config:

<meta name="javascript_config" content="<%= @view_model.javascript_configuration.to_json %>">

Then your app's init function could call the relevant initializer with the config as follows:

window.MyApp = {
  init: function () {
    var controller = window[getControllerName()]
    var action
    var config = getConfig()
    if (controller) action = controller[getActionName()]
    if (action && typeof action.init === 'function') action.init(config)
  }
}

// …

function getConfig () {
  return JSON.parse(getMeta('javascript_config') || null)
}

Lastly, we'll need to teardown the results of the initialization. To do this, we'll store the return value of an object's init, and if it includes a destroy function, we'll call it:

;(function () {
  var result

  window.MyApp = {
    init: function () {
      var controller = window[getControllerName()]
      var action
      var config = getConfig()
      if (controller) action = controller[getActionName()]
      if (action && typeof action.init === 'function') {
        result = action.init(config)
      }
    },
    // …
    destroy: function () {
      if (result && typeof result.destroy === 'function') result.destroy()
      result = undefined
    }
  }
})()

To put it all together:

;(function () {
  var result

  window.MyApp = {
    init: function () {
      var controller = window[getControllerName()]
      var action
      var config = getConfig()
      if (controller) action = controller[getActionName()]
      if (action && typeof action.init === 'function') {
        result = action.init(config)
      }
    },

    destroy: function () {
      if (result && typeof result.destroy === 'function') result.destroy()
      result = undefined
    }
  }

  function getControllerName () {
    return getMeta('controller_name')
  }

  function getActionName () {
    return getMeta('action_name')
  }

  function getConfig () {
    return JSON.parse(getMeta('javascript_config') || null)
  }

  function getMeta (name) {
    var meta = document.querySelector('[name=' + name + ']')
    if (meta) return meta.getAttribute('content')
  }
})()

Obviously this approach will only work for one init per page, so you may want to adapt it to work with more. Implementing this is beyond the scope of this answer, but you may wish to look into T3, which handles all this for you!