Targeting Links in or out of the 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

Head to the cart page and click the feature product to go to its page. Whoa. It disappeared! And... we're still on the cart page. Head to the console. Ah, that's a familiar error!

Response has no matching <turbo-frame id="cart-sidebar">. This shows off the true main property of a <turbo-frame>: any navigation inside of a frame - whether you click a link or fill out a form - will stay inside that frame.

Refresh. When we click this link, it does make an Ajax request to the "inflatable sofa" product page: you can see it down here in the network tools. It then looked for a cart-sidebar turbo frame on that page because it wants to find which part of this page it should render inside of the cart-sidebar frame.

But... in this case, that is not what we wanted! We wanted to leverage the nice, lazy-loading coolness of the turbo frame... but after that... we kind of want all its links and forms to navigate like normal.

target="_top" For "Normal" Navigation

No problem. Open the template for the cart page: templates/cart/cart.html.twig. On the <turbo-frame>, add target="_top".

... 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')) }}" target="_top">
Loading...
</turbo-frame>
</aside>
... lines 17 - 32

That's it! The _top means that any links or forms inside of this frame should target the main page. You can also change the target on just a specific link or form instead of the entire frame... and we'll see how later.

Anyways, if we refresh now... and click. It's back to normal. If you go back to the shopping cart and click to add the item to your cart, this also works. That just submitted a form... which was also broken a minute ago before we added target="_top".

Adding Attributes on the Initial Frame or Ajax-Loaded Frame?

But... wait a second. We just added target="_top" to the turbo frame in cart.html.twig. But what about the turbo-frame over here in _featuredSidebar.html.twig? This is the frame that's actually loaded via Ajax.

Let's talk about a small - but important - detail about turbo frames. When we initially load the cart page, all its HTML comes from cart.html.twig. This means that what we're originally loading on the page is a turbo-frame with a src attribute and a target attribute.

But what happens after it makes the Ajax request? Does the turbo-frame from the Ajax request replace the existing one that loaded on the page originally? Or... does it keep the original turbo-frame tag and only use the new frames inner HTML?

The answer is that a turbo frame only uses the inner HTML. So whatever attributes your frame starts with - like src and target - it will keep those, regardless of the attributes on any turbo-frame that it loads later via Ajax. Well, the the src attribute changes to the new URL, but that's it.

We can see this over in our browser. Inspect this frame: this turbo-frame has src and target="_top". So, when the new frame loaded via Ajax, that frame didn't replace this one: we know that because only the original frame has target="_top".

Anyways, this is why we added target="_top" to the frame in cart.html.twig: our original frame.

But... in _featuredSidebar.html.twig, I'm also going to add target="_top" here.

<turbo-frame id="cart-sidebar" target="_top">
<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>

Why? Well, functionally-speaking, it makes no difference. But conceptually, if you look at this frame in isolation, its links - like this link and the form down here - are not designed to navigate in the frame. Both are really meant to target the main page. Adding target="_top" here makes that clear.

And also, if we ever simply use Twig's include() function to include this template directly on a page, the frame would already have the target="_top" that it needs. Though, an even better way to guarantee that a link has the right target is to add it to the link itself - which we'll see soon.

So now that we've made this turbo frame not to keep its navigation inside of itself, let's see a real example of when keeping the normal turbo-frame behavior is awesome.

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