Turbo Frames
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOn this, day 10: we're going to talk about an ancient concept: frames. If you're old enough on the Internet, like me, you might remember iframes. They were these weird things where you could separate your site into different pieces. And when you clicked a link inside a frame, the navigation stayed inside that frame. It was like having separate web pages that you cobbled together into one.
The second part of Turbo is Turbo Frames... which is a not weird, modern way to break your page down into parts... kinda similar to iframes.
See this left sidebar? When we click a planet, it takes us to the show page for that planet. Cool. But not cool enough! Instead, when I click a planet, I want that content to load right inside of this sidebar without changing pages.
Adding the <turbo-frame>
To do that, find the sidebar: it's over in templates/main/homepage.html.twig... up near the top. This partial renders that planet list. To make this a frame, find the element that surrounds it and change it to <turbo-frame>. And the one rule of frames is that each needs to have an id attribute. It should be something unique that describes what it holds. How about planet-info:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| <aside class="hidden md:block md:w-64 bg-gray-900 px-2 py-6"> | |
| // ... line 8 | |
| <turbo-frame id="planet-info"> | |
| {{ include('main/_planet_list.html.twig') }} | |
| </turbo-frame> | |
| </aside> | |
| // ... lines 13 - 60 | |
| </div> | |
| {% endblock %} |
Ok: what does that do? At first, nothing. A <turbo-frame> is just an HTML element like a div, and so it renders normally. Though, for styling purpose, turbo-frame is an inline element by default.
However, when we click a link... it's busted! It says "Content missing". And in the console:
The response did not contain the expected
<turbo-frame id="planet-info">.
When we click this link, it makes an Ajax request to the show page... like it normally would with Turbo. But because the link is inside a <turbo-frame>, it grabs the HTML and looks for a matching <turbo-frame> with id="planet-info". If it finds that, it grabs the content inside and puts just that in the <turbo-frame> over here.
Adding the Matching <turbo-frame>
This means that each link inside a <turbo-frame> - whatever page it goes to - that page must have a matching <turbo-frame>.
Copy this <turbo-frame id="planet-info"> and then open planet/show.html.twig. Put this around the content that we want to load into the sidebar. I don't really want the h1... so put it around this table. Add the closing </turbo-frame> at the bottom:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 8 | |
| <turbo-frame id="planet-info"> | |
| <table class="min-w-full bg-gray-800 text-white"> | |
| // ... lines 11 - 33 | |
| </table> | |
| </turbo-frame> | |
| // ... lines 36 - 47 | |
| </div> | |
| {% endblock %} |
Refresh! And click. How cool is that? It makes an AJAX request to this page, grabs just the <turbo-frame> content and puts it here.
You know what else would be great? A "back" link! To add that, still inside the <turbo-frame>, add a <div class="mt-2"> then an a, href set to {{ path() }}. Link to the homepage:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 8 | |
| <turbo-frame id="planet-info"> | |
| <table class="min-w-full bg-gray-800 text-white"> | |
| // ... lines 11 - 33 | |
| </table> | |
| <div class="mt-2"> | |
| <a href="{{ path('app_homepage') }}"><-- Back</a> | |
| </div> | |
| </turbo-frame> | |
| // ... lines 40 - 51 | |
| </div> | |
| {% endblock %} |
Try it! We don't even need to refresh. Behold! A back link! Whoops, let's make that more of an arrow. When we click it... it goes back! That made an AJAX request to the homepage and looked for a matching <turbo-frame id="planet-info">. And guess what that holds? This list of planets.
We're on a roll! Before we finish today, add one more link: an edit link. The route is app_planet_edit... with id set to planet.id:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 8 | |
| <turbo-frame id="planet-info"> | |
| // ... lines 10 - 35 | |
| <div class="mt-2"> | |
| <a href="{{ path('app_homepage') }}"><-- Back</a> | |
| <a href="{{ path('app_planet_edit', {'id': planet.id}) }}">Edit</a> | |
| </div> | |
| </turbo-frame> | |
| // ... lines 42 - 53 | |
| </div> | |
| {% endblock %} |
Cool! this time, if we click a planet... then edit... it doesn't work! And I bet you can guess why. It made an AJAX request to the edit page.... but there is no matching <turbo-frame> on that page. And so, we get this error.
But... I don't want to add a <turbo-frame> to the edit page. The form wouldn't fit into the sidebar anyway. Nope, when I click this link, I want it to result in a "full page" Turbo navigation.
As soon as you add a <turbo-frame>, you need to keep track of the links that you have inside of it and either make sure that each goes to a page that has a matching <turbo-frame>.... or that you target the link or form to do a full visit.
Targeting Links to the Full Page
How do you do that? Find the link, and add data-turbo-frame - that's a typo Ryan - set to _top:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 8 | |
| <turbo-frame id="planet-info"> | |
| // ... lines 10 - 35 | |
| <div class="mt-2"> | |
| // ... lines 37 - 38 | |
| <a data-turbo-frame="_top" href="{{ path('app_planet_edit', {'id': planet.id}) }}">Edit</a> | |
| </div> | |
| </turbo-frame> | |
| // ... lines 42 - 53 | |
| </div> | |
| {% endblock %} |
Now, without refreshing, hit edit. It still breaks. I did the wrong thing. It's data-turbo-frame="_top". There we go.
Now hit edit. Full page navigation! It's still Ajax-powered, but the whole page changes.
The other way to target links or forms to the full page is on the <turbo-frame> itself. You might say:
Hey! I want all links in this
<turbo-frame>to be full page navigation by default.
To do that, set target to _top. Then, if you have a specific link that you want to load in this frame, add data-turbo-frame equals and then the id: planet-info.
Both approaches are fine: your call. But adding target="_top" to each frame is a bit safer.
Hiding Content Not in a Frame
So this is working super well! Except for the fact that if the user does ever get to the planet show page, we have an extra set of links. We really only want to show those when we're inside the <turbo-frame>. How can we do that?
When Turbo sends an Ajax request for a <turbo-frame>, it does add a request header that tells your app that this is a Turbo Frame request. You can use that inside Symfony to conditionally do different things... like conditionally render these links.
We are going to do that one time later in the tutorial. However, I try to minimize this: it adds complexity. Another option is to hide extra stuff with CSS! For example, we could add a class onto the sidebar... then only show these links if we're inside that class.
However, Tailwind doesn't really work like that. In Tailwind, you can't change styling conditionally based on your parent. At least not out-of-the-box. But we can do this with a trick called a variant.
The first thing to notice is that a <turbo-frame>, by default, looks like this: exactly like we have in our template. But as soon as we click a link, it has a src attribute. We can take advantage of that by adding a way inside of Tailwind to style elements conditionally based on whether they are inside of a <turbo-frame> that has a src attribute. Because, it will have a src over here... but won't have a src inside of this <turbo-frame>... because it never navigates. In fact, it would be a good idea to add a target="_top' to this frame, since we don't need fancy frame navigation on this page.
Anyway, Tailwind variants are a bit more advanced, but simple enough. Import this plugin module, then go down to plugins. I'll paste in some code:
| const plugin = require('tailwindcss/plugin'); | |
| /** @type {import('tailwindcss').Config} */ | |
| module.exports = { | |
| // ... lines 5 - 12 | |
| plugins: [ | |
| plugin(function({ addVariant }) { | |
| addVariant('turbo-frame', 'turbo-frame[src] &') | |
| }), | |
| ], | |
| } |
This adds a variant called turbo-frame: you'll see how we use that in a second. It basically applies to an element that's inside a <turbo-frame> that has a src attribute.
Because we called this turbo-frame, copy that. Now, in show.html.twig, add a hidden class to hide this div by default.
When we refresh, it's gone. But then, if we match our turbo-frame variant, change to display block:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 8 | |
| <turbo-frame id="planet-info"> | |
| // ... lines 10 - 35 | |
| <div class="mt-2 hidden turbo-frame:block" > | |
| <a href="{{ path('app_homepage') }}"><-- Back</a> | |
| <a data-turbo-frame="_top" href="{{ path('app_planet_edit', {'id': planet.id}) }}">Edit</a> | |
| </div> | |
| </turbo-frame> | |
| // ... lines 42 - 53 | |
| </div> | |
| {% endblock %} |
Check it out. When we refresh, those links are still hidden. But over here... we've got them! Because we're inside a turbo-frame with a src attribute, our variant activates and the display block takes over.
Turbo Frames do add some complexity, but we've only started to scratch the surface on what they make possible.
Tomorrow, when I hover over each planet, I want to add a cool popover with more planet info. To make that happen, we're going to install another third-party Stimulus controller.
13 Comments
Little typo, id="planet-info">
Got it! thanks! https://github.com/SymfonyCasts/30-days-last/commit/1d963c2a8dc16ea7aaa25da063724c680e07a1b1
How do I share the state of the page where clicked planet links gets planet content into left div? I don't see that url has been changed.
Hey @Filip!
If you need this, then you need the URL to change. We show how to do that here - https://symfonycasts.com/screencast/last-stack/data-tables#advancing-the-frame
In this case, the only weird thing would be that the URL would change to something like
/planets/5... and when you refresh, instead of seeing the homepage with a planet sidebar, you'd see the planet show page. So, if I had this requirement in this situation, I'd probably make my homepage able to have a planet id in it - e.g./?planet=5or/{planet}if you want. Then, I'd make my links on the sidebar go to this URL. And in the homepage template, I'd render a specific planet if the planet is in the URL. Then allowing the URL to change to/?planet=5would work perfectly when you refresh.Cheers!
The links remain hidden for me when the content is loaded in the turbo frame using src despite using turbo-frame:block in the class for the div.
I've added the plugin in the tailwind.config.js as directed.
I've managed to get it working now. I must admit that I took a break from this incredibly fantastic course during the holidays, and only today have I resumed it so it might have been something that stopped working due to that. I've tried
asset-map:compile+tailwind:build -wand a good refresh of the page and it did the trick.Thank you, I met the same issue too.
Hey @apphancer!
Ah, happy it's working now! The ol' "unplug it and plug it back in" fix 😆. And really happy you're enjoying the tutorial - I had a blast making it.
Cheers!
I'm using Turbo frames, and I get weird behavior with the back button on the browser. I use turbo-advance to change the URL, and that works well enough, but when I go back, the navbar that isn't in the main frame stops working. Side note, I'm using Tailwind Nav drop down, and I was struggling to get it to close on a turbo visit, so I wrote a controller to simulate a 'click outside event', which works,well, but even if I don't use that controller, Turbo doesn't behave right with my navbar. I'd really like the back and forward buttons on the browser to work, but I also really like Turbo Frames, can I have both?
Hey @Brandon!
Hmm. Short answer: you can disable the turbo page cache. It's not a super important part of Turbo and it tends to be the toughest part to get right.
Long answer: you said you're using
Tailwind Nav drop? What is that exactly - is it from Flowbite? What is the JavaScript that powers it? Basically, my guess is that the JavaScript isn't written in a way where it doesn't like being removed from the page then re-added (this is what happens when you hit Back: Turbo originally took a snapshot of the previous page when you first navigated off of it, then when you hit Back, it put that snapshotted HTML back). This is why having the JS in Stimulus is important. But, it is super frustrating that libraries (e.g. Bootstrap's js or Flowbite) are written in a way where they don't work without full page reloads (though Flowbite DOES have a Turbo-specific file... which I haven't tried, but should work - https://cdn.jsdelivr.net/npm/flowbite@2.2.1/dist/flowbite.turbo.js/ esmOh, and btw - this isn't an issue of Turbo frames, but of what happens when Turbo navigates in general - either via a Turbo frame with
action="advance"or with a normal Turbo Drive navigation.Let me know if this helps!
Cheers!
I must be very super old because I remember the frames (framesets) that came before iframes. Dang!
Oh yea, framesets! I remember those too. But I don't remember (at least not anymore) frames before iframes. So iframes were the "new fancy thing at some point?). Lol
Hey Julien,
Dang! 😅 Well, you know the whole history from the very beginning then I suppose, good enough to see the evolution of it 🙃
Cheers!
"Houston: no signs of life"
Start the conversation!