I totally get why Turbolinks 5 is awesome and if you're reading it, you probably do as well, but I am very frustrated with how badly it plays with the other scripts on the block.
To date, there is no simple explanation (human readable) that shows how to wrap existing jQuery scripts in a way that would allow them to function.
Take for example this one: https://github.com/Bttstrp/bootstrap-switch. It's well written, simple to understand. You load the js and css to your assets pipeline and instantiate it on some page.
# view.html.erb
<input type="checkbox" class="switch"> switch button
<script type="text/javascript">
$(".switch").bootstrapSwitch();
</script>
you go to view.html, click another page, click back and you see two buttons.
Next, you spend 5 hours looking for a way to have Turbolinks load the instance of bootstrapSwitch only once if not loaded before. Well, even if you do, the functionality will be gone. Clicking it will not work.
$(document).on("turbolinks:load", function()...
will load it on every Turbolink visit, and for now, the only way I could make it work and not create duplicates was to disable cache on view.html with
<%= content_for :head do %>
<meta name="turbolinks-cache-control" content="no-cache">
<% end %>
Which feels kinda stupid.
I think it all has something to do with using idempotent - https://github.com/turbolinks/turbolinks#making-transformations-idempotent but how do you practically do this?
Could someone please take this simple plugin as an example and share a simple, elegant solution for making it work which we can then reproduce with other scripts?
Developing apps with Turbolinks does require a particular approach in order to get things running smoothly. Due to differences the way pages are loaded and cached, some patterns of running scripts won't behave in the same way with Turbolinks vs. without. This may seem unfriendly at first, and the "gotchas" can be frustrating, but I've found that with a little understanding, it encourages more organised, robust code :)
As you have figured out, the problem with duplicate switches is that the plugin is being called more than once on the same element. This is because Turbolinks caches a page just before navigating away from it, and so the cached version includes any dynamically added HTML[1] e.g. stuff added via plugins. When navigating back/forward, the cached version is restored, and the behaviour is duplicated :/
So how to fix this? When working with code which adds HTML or event listeners, it is generally a good idea to teardown behaviours before the page is cached. The Turbolinks event for that is turbolinks:before-cache
. So your setup/teardown might be:
// app/assets/javascripts/switches.js
$(document)
.on('turbolinks:load', function () {
$('.switch').bootstrapSwitch()
})
.on('turbolinks:before-cache', function () {
$('.switch').bootstrapSwitch('destroy')
})
This is a bit difficult to test since all the setup and teardown is done in event handlers. What's more, there maybe many more cases like this, so to prevent repitition, you may want to introduce your own "mini-framework" for setting up and tearing down functionality. The following walks through creating a basic framework.
Here's is what we'll aim for: calling window.App.addFunction
with a name and a function registers a function to call. That function gets the elements and calls the plugin. It returns an object with a destroy
function for teardown:
// app/assets/javascripts/switches.js
window.App.addFunction('switches', function () {
var $switches = $('.switch').bootstrapSwitch()
return {
destroy: function () {
$switches.bootstrapSwitch('destroy')
}
}
})
The following implements addFunction
, storing added functions in the functions
property:
// app/assets/javascripts/application.js
// …
window.App = {
functions: {},
addFunction: function (name, fn) {
this.functions[name] = fn
}
}
We'll call each function when the app initializes, and store the result of each function call in the results
array if it exists:
// app/assets/javascripts/application.js
// …
var results = []
window.App = {
// …
init: function () {
for (var name in this.functions) {
var result = this.functions[name]()
if (result) results.push(result)
}
}
}
Tearing down the app involves destroying calling destroy
(if it exists) on any results:
// app/assets/javascripts/application.js
// …
window.App = {
// …
destroy: function () {
for (var i = 0; i < results.length; i++) {
var result = results[i]
if (typeof result.destroy === 'function') result.destroy()
}
results = []
}
}
Finally we initialise and teardown the app:
$(document)
.on('turbolinks:load', function () {
window.App.init.call(window.App)
})
.on('turbolinks:before-cache', window.App.destroy)
So to put this all together:
;(function () {
var results = []
window.App = {
functions: {},
addFunction: function (name, fn) {
this.functions[name] = fn
},
init: function () {
for (var name in this.functions) {
var result = this.functions[name]()
if (result) results.push(result)
}
},
destroy: function () {
for (var i = 0; i < results.length; i++) {
var result = results[i]
if (typeof result.destroy === 'function') result.destroy()
}
results = []
}
}
$(document)
.on('turbolinks:load', function () {
window.App.init.call(window.App)
})
.on('turbolinks:before-cache', window.App.destroy)
})()
Functions are now independent of the event handler that calls them. This decoupling has a couple of benefits. First it's more testable: functions are available in window.App.functions
. You can also choose when to call your functions. For example, say you decide not to use Turbolinks, the only part you'd need to change would be when window.App.init
is called.
[1] I think this is better than the default browser behaviour (where pressing "Back" returns the user back to the page as it was when it was first loaded). A Turbolinks "Back" returns the user back to the page as they left it, which is probably what a user expects.