If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeI 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.
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')
.
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.
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.
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.
Hey Alcides!
Thanks for the nice words - I appreciate it! ❤️
As far as I understood turbo frame is not SEO friendly right?
Well.... yes and no. It IS true that, even if a search engine crawler supported JavaScript, if they click a link inside a Turbo frame, the URL in the address bar wouldn't change... so I imagine that they wouldn't do any indexing on that. In other words, they would effectively ignore that link.
However, one of the beautiful things about turbo frames is that the links are REAL links, where the href=""
points to a real page. For example, suppose you have a sidebar with a <a href="/news">News</a>
. If you think about it, you don't need the crawler to properly click your turbo frame link and have it load in the frame... because this is a REAL link. So it WILL find the "/news" page and index that as its OWN page.
I hope this makes sense :). Ultimately, you build real pages first (e.g. /news) which of course ARE indexable. Then, Turbo Frames are a way to "spice up" your site by making the content on those real pages visible elsewhere on your site.
Cheers!
Thanks for your very nice response.
Good point and I agree with you that crawler will follow the links since they are real links.
But what I had in mind is that since frame's tag is a custom html's tag and the content inside it is load via ajax even in the first page load I wonder if crawlers will be available to index that content.
Cheers
Hey Alcides!
Sorry for the slow reply!
> But what I had in mind is that since frame's tag is a custom html's tag and the content inside it is load via ajax even in the first page load I wonder if crawlers will be available to index that content.
Ah! The custom tag is no problem - any HTML tags are legal, I'm pretty much positive that crawlers won't care about this. About the AJAX call, it's possible crawler's will have issues with it... though I think they're pretty good these days. If your content is important enough, just avoid the lazy-loading part of a frame: load the HTML into the frame on page load, instead of using the src= attribute. So, you'll need to make the best choice based on how important that content is and how worried you are that it may not get parsed if you load it lazily :).
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"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.18.5
"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
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.13.13
"@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
"@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
"@popperjs/core": "^2.9.1", // 2.9.2
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
"@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.3.0
"bootstrap": "^5.0.0-beta2", // 5.0.1
"chart.js": "^2.9.4",
"core-js": "^3.0.0", // 3.13.0
"jquery": "^3.6.0", // 3.6.0
"react": "^17.0.1", // 17.0.2
"react-dom": "^17.0.1", // 17.0.2
"regenerator-runtime": "^0.13.2", // 0.13.7
"stimulus": "^2.0.0", // 2.0.0
"stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
"stimulus-use": "^0.24.0-1", // 0.24.0-2
"sweetalert2": "^11.0.8", // 11.0.12
"webpack-bundle-analyzer": "^4.4.0", // 4.4.2
"webpack-notifier": "^1.6.0" // 1.13.0
}
}
Hi Ryan. As far as I understood turbo frame is not SEO friendly right? By the way great Stimulus and Turbo tutorials.