Turbo-Friendly JavaScript

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

The biggest gotcha with Turbo Drive is JavaScript. And that's for one simple reason: suddenly there are no full page refreshes! And... a lot of JavaScript is written to expect that behavior.

How JavaScript in head Is Parsed

Let's see how some classic JavaScript behaves with Turbo. Open assets/app.js: this file is loaded on every page. Let's use jQuery to run some code after the page finishes loading. You might recognize this code.

Import $ from jquery - I already have that installed. Then use $(document).ready() and pass a function that should be called once the page is fully loaded with console.log('page is ready'). After this block, also console.log('script is done').

20 lines assets/app.js
... lines 1 - 14
$(document).ready(() => {
console.log('page is ready!');
})
console.log('script is done!');

Cool. Go refresh... and check out the console. Yep! We see both logs: script is done first, then "page is ready" shortly after. But when we click to another page, we see nothing! And that makes sense! app.js is not re-executed... and the page does not become "ready" again. This is a big difference compared to a traditional web app. But, it's also what makes Turbo so fast: re-parsing all that JavaScript over and over again on each page load takes time!

The Problem of JavaScript in body

However, if you put JavaScript into the body of your page, then it does work like normal. Open up templates/base.html.twig and - anywhere in the body, I'll go to the bottom - add a script tag and console.log('body executing!').

... lines 1 - 80
</div>
<script>
console.log('body executing!');
</script>
</body>
</html>

Refresh now. We see all three logs. Click to another page. Hey! The new log is there! And... this also makes sense. Turbo replaces the old body with the new body. And so, any script tags in the new body are parsed & executed.

But... this is not necessarily a good thing... for two reasons! First, re-parsing the same JavaScript on every page is wasteful and can slow down your page. That's what Turbo Drive helps us avoid.

Second, putting JavaScript into your body can... sometimes cause weird things to happen. Watch closely: I'm going to clear my console.... then click back to a page that I just visited a minute ago. Woh! There are two logs!

This logged once when the page preview was shown from cache and a second time when the fresh HTML was rendered. This... might be okay? Logging two messages doesn't hurt anything. But this might cause some big problems in other situations, like double-counting page views in an analytics system. The topic of external JavaScript is something we'll dive into a bit later.

Here's another issue. Suppose you - or some third-party JavaScript library - adds an event listener to the entire document. Go back to base.html.twig. Use the document variable. document basically represents the html tag, which unlike the body, is never replaced by Turbo. Well, technically, document is sort of like the owner of the html element... but that's not important here.

Anyways, add an event listener to this: document.addEventListener() to listen to the click event. On click, console.log('document clicked').

... lines 1 - 80
</div>
<script>
document.addEventListener('click', () => {
console.log('body clicked!');
})
</script>
</body>
</html>

We should be able to click anywhere to see this message. Refresh, go to the console and... click. There it is! Click again and another log! Easy peasy.

Now clear the console and click to another page. Oh, let's clear the console again. And... click. Ah! Two logs! That is definitely not what we want!

This happens because, each time we execute the script, it adds another listener to the document. After 10 clicks, our function would be called 10 times!

Go remove the script tag and the jQuery loading code.

... lines 1 - 76
<div
class="footer mb-0"
{{ stimulus_controller('made-with-love') }}
>
</div>
</body>
</html>

13 lines assets/app.js
... lines 1 - 7
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.css';
// start the Stimulus application
import './bootstrap';

Writing JavaScript that you (and Turbo) will Love

So... what is the best way to write JavaScript so it works nicely with Turbo Drive? Well... Stimulus of course!

We already know from the first tutorial in this series that if a new data-controller element appears on the page - like data-controller="counter", which powers this contest area up here, its Stimulus controller will always work perfectly, even if that HTML is loaded via Ajax. That is the most powerful part of Stimulus and it works brilliantly with Turbo.

One other lesson is that you should probably remove any JavaScript that you have inside your body element... even though it mostly works. That's because of the potential for the bad behavior that we saw a minute ago. In a little while, we'll talk about external JavaScript - like widgets or analytics - which are often supposed to be added to your body.

But let me be clear about one thing: I do not want you to think about all of this like:

Hey! Turbo is forcing me to write my JavaScript a certain way!

Nope: Turbo is forcing you to write better JavaScript: JavaScript that only needs to be loaded and executed once... and then keeps on working forever, even as new content is loaded onto the page.

So this whole JavaScript topic is definitely the biggest hurdle to using Turbo Drive. Until you have all the JavaScript on your site written properly, things won't work well. But you can fix the JavaScript for just some pages on your site and activate Turbo Drive only for those. Let's see how next and also learn how we're able to put all of our script tags into the head element without hurting page-load performance.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}