How Turbo Drive Works

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.

This is Turbo Drive. And yes, it feels like absolute magic. So let's break down how this works.

How Was Turbo Activated? The Magic Stimulus Controller

To start... we never wrote any JavaScript that said:

Hey Turbo! Please activate your Drive functionality.

So... how would did this automatically start working? That is thanks to the magic of the assets/controllers.json file. This is normally a mechanism in Symfony UX for third party libraries to add new Stimulus controllers to our app. And in this case, that's true... but it's kind of a trick.

Let's go find the file that's being referenced. It lives in node_modules/@symfony/ux-turbo then src/turbo_controller.js. If you're wondering how I knew to open this exact file... this turbo-core string here matches up with a special key inside of the package.json file of this library. So turbo-core points to dist/turbo_controller.js. So, technically the file in the dist/ folder is loaded... but I'm opening the original in src/ because it's a bit easier to read.

And... there's not much here! This exposes an empty controller. And really, the whole point of this file is to import Turbo and set it onto the window variable. This accomplishes two things. First, when you import Turbo, it automatically activates Turbo Drive across your entire site. We'll talk about how to disable it globally or selectively a bit later. And second, Turbo is set onto the window variable, which makes it a global variable. You may or may not need this. It's useful if you need to programmatically visit a link, but from outside a JavaScript file. We'll see that later.

So we now know who activated Turbo. But... how the heck does Turbo Drive work? It's a pretty simple idea. Turbo watches link clicks - and also form submits like this add to cart form submit - and intercepts them. It then performs those requests in the background via an Ajax call, which we can see here. When that finishes, it updates the HTML of the page from the HTML in the response... all without a full refresh. But, it does modify the URL, which gives us normal browser behavior, like clicking back and forward.

Snapshots & Previews

Speaking of back and forward, Turbo Drive has a feature called "snapshots". Let me refresh the page real quick. As you navigate to a new page, it stores a "snapshot" of the page you're leaving. Then, if you click back in your browser, it instantly loads that snapshot with no network request. It does the same if you go forward. And if you revisit a page that you've already been to, so, a page whose snapshot has been stored, Turbo will give you an instant "preview" of that page while it waits for the Ajax call for that page to finish. You can see how super fast the pages are that we've already gone to versus ones that we have not gone to yet. By the way, this snapshot cache isn't persistent: it clears when you refresh the page.

Some of this preview & snapshot stuff is kind of hard to see because things are so fast. So in your editor, open up public/index.php and add a sleep() for two seconds.

10 lines public/index.php
<?php
use App\Kernel;
sleep(2);
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
... lines 7 - 10

Now head back to your browser and refresh the page... which takes 2 seconds. Click back to the homepage. Oh! This shows off the progress bar! If an Ajax call takes longer than 500 milliseconds, the progress bar shows up, which you can customize with CSS if you want. Because our site is slow, we see it each time we click to a new page.

But now, let's click back to "Office Supplies", which we visited before. When I do, watch closely: the page will show up instantly. Boom! Then it finishes loading the Ajax call. This is the preview feature. When you navigate to a page you've already been to, Turbo loads the page from cache for an instant experience. But it still makes an Ajax call for the page. And when that finishes, it takes the new HTML and renders it onto the page. Most of the time - like right now - we don't really notice that Ajax call finishing... because the new HTML is identical to the preview.

And if we click backward and forward, as we mentioned earlier, those pages load instantly with no Ajax request. Let's go take out that sleep.

10 lines public/index.php
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
... lines 7 - 10

Merging of the <head> Tag

Okay... but how does this all really work? What is Turbo doing behind the scenes to make it all happen? Let's go a step deeper. This is important because, to get Drive working happily on your site, as the saying goes, the devil is in the details. We'll spend the first part of this tutorial talking about potential problems that Turbo Drive can cause and how to fix them.

Let me refresh the page again to clear the snapshot cache.

Okay: when we click a link, Turbo intercepts that and makes an Ajax call instead. Oh, by the way, these extra Ajax requests are for the web debug toolbar.

Anyways, the Ajax request that Turbo makes when we click returns a full HTML page. When Turbo gets that full HTML response, it merges the new head tag into the existing head tag and then replaces the body with the new body.

The way it merges the old and new head is smart. Go over to the Elements part of the debugging tools and open up the head tag.

When the Ajax request finishes, Turbo first finds anything in the head other than JavaScript and CSS elements and removes those. Then it looks in the new head for any non JavaScript and non CSS elements and adds those.

We can actually see this. Reload the page and look back at the head. I see two non-JavaScript and non-CSS tags: a meta tag with the charset and the title element. When I click to go to another page, these will be removed. Then, any elements from the new page's head will be added to the bottom. And... boom! The new page happens to have the same two tags, but you can see that the original ones were removed and the new ones added. I was lazy and didn't give each page a unique title, but if the next page did have a new title, it would show up.

How JavaScript & CSS is Handled

Let's talk about how JavaScript and CSS is handled. If the new head tag contains JavaScript or CSS tags - and it probably will, since we're returning full HTML pages - Turbo checks to see if these elements already exist in the current head. If they do - like the next page we click also has a script tag for build/runtime.js - then Turbo ignores it. There's no reason to add the same script or CSS multiple times. But if the CSS or JavaScript element does not exist on the current page, it will add it. This is actually a big reason why Turbo Drive feels so fast: each time you navigate, your browser does not need to re-parse all of your JavaScript and CSS like it normally would with a traditional full page reload.

The result of all of this is... exactly what we see as we click around! The title changes on each page - the login page has a different title - and if a page contained new JavaScript or CSS, that would be added automatically.

So... this is amazing! Well, yes, it is amazing. But to get this amazingness to work perfectly, there is a little bit of work that we need to do. The first bit involves making your JavaScript Turbo-friendly. Let's dive into that topic next.

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/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
    }
}