Using a Full HTML Page to Populate a 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

I want to show one more lazy frame example. But before we do, I'm going to find my terminal and, yes, once again, run:

yarn upgrade @hotwired/turbo

This time I get beta version 8, which is actually the release I was waiting for. This changes how JavaScript is handled inside frames, which will be important for what we're about to do.

But for a minute, I want you to completely forget about frames. Let's pretend that we, being the nerds that we are, want to add a weather page to our site! Sure, we have this weather footer on the bottom of every page, but we also want people to be able to go to /weather and see the weather report front and center.

Creating a Normal Weather Page

Over in src/Controller/, create a new class called WeatherController. Make it extend AbstractController and add a public function weather() with a route above it: @Route('/weather'), name="app_weather". Inside, return $this->render('weather/index.html.twig').

<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class WeatherController extends AbstractController
{
/**
* @Route("/weather", name="app_weather")
*/
public function weather()
{
return $this->render('weather/index.html.twig');
}
}

Cool! Let's go make that template! Down in templates/, create a new directory called weather/, and, inside, the new file: index.html.twig. Give this the basic structure {% extends 'base.html.twig' %}, {% block body %}, {% endblock %} and an <h1>.

Now go into base.html.twig and... at the bottom, steal all of the weather stuff: the anchor tag and the script element. In index.html.twig, paste.

{% extends 'base.html.twig' %}
{% block body %}
<h1>The Weather!</h1>
<a class="weatherwidget-io" href="https://forecast7.com/en/40d71n74d01/new-york/" data-label_1="NEW YORK" data-label_2="WEATHER" data-theme="original" >NEW YORK WEATHER</a>
<script>
!function (d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (!d.getElementById(id)) {
js = d.createElement(s);
js.id = id;
js.src = 'https://weatherwidget.io/js/widget.min.js';
fjs.parentNode.insertBefore(js, fjs);
}
}(document, 'script', 'weatherwidget-io-js');
</script>
{% endblock %}

Done! Oh, but in base.html.twig, let's add a link to this... find the cart link - there it is - copy it, paste, change the route to app_weather and... for the text, I'll use a FontAwesome icon: fas fa-sun.

... lines 1 - 29
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ path('app_weather') }}">
<span class="fas fa-sun"></span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_cart') }}">
Shopping Cart ({{ count_cart_items() }})
</a>
</li>
... lines 41 - 110

Let's go check it out! Move over, refresh and... there's our sunshine! When we click the icon, we have a weather page. Amazing!

Though... having two weather widgets on the page does look weird. Let's remove the one in the footer for just this page. In base.html.twig, scroll back down to that area. Surround this in a new {% block weather_widget %} and, on the other side, {% endblock %}.

... lines 1 - 88
{% block weather_widget %}
<a class="weatherwidget-io" href="https://forecast7.com/en/40d71n74d01/new-york/" data-label_1="NEW YORK" data-label_2="WEATHER" data-theme="original" >NEW YORK WEATHER</a>
<script>
!function (d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (!d.getElementById(id)) {
js = d.createElement(s);
js.id = id;
js.src = 'https://weatherwidget.io/js/widget.min.js';
fjs.parentNode.insertBefore(js, fjs);
}
}(document, 'script', 'weatherwidget-io-js');
</script>
{% endblock %}
... lines 103 - 112

Back in index.html.twig, anywhere, override that block... but make it empty.

... lines 1 - 18
{% block weather_widget %}{% endblock %}

Ok, refresh again and... cool!

At this point, we do have some code duplication between index.html.twig, and base.html.twig. We could easily fix that by isolating the weather widget code into its own template... and then using the Twig {{ include() }} function in both templates to bring that in.

Creating the Lazy Turbo Frame

But like we did with the featured product sidebar, I want you to pretend that it takes a lot of work to generate this HTML... maybe we make some database calls or API calls to generate it. And so, if we could convert the weather widget that's on the footer of every page into a lazy turbo frame, well, that would make every page load faster!

When we created a lazy turbo frame for the featured product sidebar, we started by making a route and a controller that rendered just that part of the page: just the featured product itself - without the layout. But this time, we're not going to do that.

Why not? Because we already have a page that contains the HTML we need! The weather page! Sure, it contains a lot of extra stuff that we don't want... like the HTML layout and the <h1> tag... but the turbo-frame system can ignore all that. Yup, we can jump straight to adding the turbo frame with zero extra work.

In base.html.twig, remove all the duplicated code and instead say, <turbo-frame id="">, how about, weather_widget. Then, because we want this to be a lazy frame, add src="" and point this at the full HTML page that we want to target: the weather page.

... lines 1 - 87
{% block weather_widget %}
<turbo-frame id="weather_widget" src="{{ path('app_weather') }}"></turbo-frame>
{% endblock %}
... lines 93 - 101

If we try this... I'll go to the homepage... it's not going to work. In the console, we see a familiar error!

Response has no matching <turbo-frame id="weather_widget"> element.

Of course! We need to tell the Turbo frame system which part of the weather page to use for this frame. Over in index.html.twig - the template for the full weather page - wrap the entire weather section in a <turbo-frame> that has id="weather_widget". I'll put the closing tag down here... and indent.

... lines 1 - 2
{% block body %}
<h1>The Weather!</h1>
<turbo-frame id="weather_widget">
<a class="weatherwidget-io" href="https://forecast7.com/en/40d71n74d01/new-york/" data-label_1="NEW YORK" data-label_2="WEATHER" data-theme="original" >NEW YORK WEATHER</a>
<script>
!function (d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (!d.getElementById(id)) {
js = d.createElement(s);
js.id = id;
js.src = 'https://weatherwidget.io/js/widget.min.js';
fjs.parentNode.insertBefore(js, fjs);
}
}(document, 'script', 'weatherwidget-io-js');
</script>
</turbo-frame>
{% endblock %}
... lines 21 - 23

Testing time! Refresh again and... it works! That's amazing! We're now able to reuse just parts of existing pages simply by wrapping those parts inside a <turbo-frame>. If you look at the network tools... and find the Ajax call for the weather page, there's no magic here: the Ajax call for the frame did return the full HTML.

And this is really how frames are meant to be used. You have an existing page like the weather page, and then you're able to reuse parts of that page inside a frame instead of needing to build an extra endpoint that returns only the part you want.

Truly Lazy Frames: Load only when Visible

Ok, ready to be more amazed? Check out the homepage: this is a long page. Don't you think it's kind of a wasteful to load the weather widget in the footer... even if the user never scrolls down that far? It is wasteful! And we can fix that!

In base.html.twig, on the turbo-frame, add a new attribute: loading="lazy",

... lines 1 - 87
{% block weather_widget %}
<turbo-frame id="weather_widget" src="{{ path('app_weather') }}" loading="lazy"></turbo-frame>
{% endblock %}
... lines 93 - 101

Let's see what that did. Scroll to the top of the homepage, refresh and make sure you're looking at the Ajax calls in the network tools. Notice that Turbo has not, yet, made an Ajax request for the weather page. But keep an eye on this. If we scroll down... there it is! Yup, when you add loading="lazy", the request isn't made until the frame becomes visible. That's super cool.

But... there's a lingering bug in our code. It's more about the JavaScript for the weather widget than about the turbo-frame we created. Let's find out what the bug is next and create a Stimulus controller that will make the weather JavaScript finally, fully functional, no matter how we load it.

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