Turbo Frames Look for & Load the Matching Frame

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

On page load, Turbo did notice our new <turbo-frame> element and it did make an Ajax request to fetch the contents. But then, for some reason, it gave us this error. Why?

This is a super important detail of Turbo frames. When a frame makes an Ajax call, it looks in the response for a <turbo-frame> element that has the same id as itself and uses its content only. If it does not find a matching <turbo-frame>, in the response, then you get this error.

Ok, but... why? If you look in the network tools, the response from the Ajax call contains the exact HTML we want. Why doesn't it just take the entire HTML from the response and put it into the frame?

Well, we're not leveraging it in this example, but one of the super powers of the frame system is that you can point a frame at a URL that returns an entire, full HTML page. So if you pretend that this returns a full HTML page, the frame system is smart enough to only find and use the matching frame. This allows you to create full, normal pages and then reuse those full normal pages to power your frames.. avoiding the need to create extra endpoints for your frames like we did. If this doesn't make sense yet, don't worry. Our next example will illustrate this.

Adding the turbo-frame in the Response

Anyways, what we need to do is make sure that the response contains a <turbo-frame> element with id="cart-sidebar". I'll copy that from cart.html.twig, open _featuredSidebar.html.twig, add that... and indent everything.

<turbo-frame id="cart-sidebar">
<div class="component-light product-show p-3 mb-5">
<h5 class="text-center">Featured Product!</h5>
<a href="{{ path('app_product', { id: featuredProduct.id }) }}">
<img
alt="{{ featuredProduct.name }}"
src="{{ asset('/uploads/products/'~featuredProduct.imageFilename) }}"
class="d-block"
>
</a>
<div class="pt-3">
<h6 class="d-flex justify-content-between mb-3">
<strong>{{ featuredProduct.name }}</strong>
{{ featuredProduct.priceString|format_currency('USD') }}
</h6>
{{ include('product/_cart_add_controls.html.twig') }}
</div>
</div>
</turbo-frame>

Notice that we don't have a src="" on this frame: this is not a lazy frame... it's just a normal frame that already has its final content.

Ok: let's try it again. Refresh and... yes! It works! It looked in the response for the <turbo-frame> with the id, found it and used its HTML. If you inspect element and find the turbo-frame, you can see the src="" attribute is still there, but now it's filled with content.

At this point, if you click any links or submit the form on the sidebar... it might not work like you expect because the frame will keep any navigation inside the frame. That's the first use-case for Turbo Frames - and we'll come back in a few minutes to address this.

Using fragment_uri()

Oh, and by the way, if you're using Symfony 5.3 and you create a controller - like this one - that just renders part of a page, you don't have to give this a route. There's another option. Remove this route.

... lines 1 - 29
public function _cartFeaturedProduct(ProductRepository $productRepository): Response
{
$featuredProduct = $productRepository->findFeatured();
$addToCartForm = $this->createForm(AddItemToCartFormType::class, null, [
'product' => $featuredProduct,
]);
return $this->renderForm('cart/_featuredSidebar.html.twig', [
'featuredProduct' => $featuredProduct,
'addToCartForm' => $addToCartForm,
]);
}
... lines 43 - 113

Now, in cart.html.twig, instead of {{ path() }}, use {{ fragment_uri() }} and then controller() and then the name of the controller: App\\Controller\\CartController:: and then the method name... which is _featuredProduct.

... lines 1 - 10
<aside class="col-12 col-md-4 order-2 order-md-1">
<turbo-frame id="cart-sidebar" src="{{ fragment_uri(controller('App\\Controller\\CartController::_cartFeaturedProduct')) }}">
Loading...
</turbo-frame>
</aside>
... lines 16 - 32

This is a bit longer - and those double slashes are ugly and needed because backslash is an escape character. Behind the scenes, this will generate a signed URL - called a fragment URL - that renders our controller. To get this to work, make sure that you have the fragment system activated: that's in config/packages/framework.yaml. Uncomment fragments: true.

... lines 1 - 14
#esi: true
fragments: true
php_errors:
log: true
... lines 19 - 25

Let's try this. Move over, refresh the page and cool! It still works! If you look at the turbo-frame, the src="" is now set to a long, weird looking _fragments URL.

Next: let's look at a second lazy frame example. But this time, instead of creating a controller that renders just the frame, we're going to populate a frame by reusing an existing, full HTML page.

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