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 SubscribeOkay, so if we don't want to cheat and use the internal restore
action with a Turbo visit, how else can we solve our problem? Well, there's really only one option. Let me reopen my network tools. Right now, when we successfully submit into a <turbo-frame>
, like this modal, the frame follows the redirect, meaning it makes a request to the redirected URL. Then we navigate to that same URL, which causes a second request to it. Somehow, we need to avoid having these two requests.
So if we can't force Turbo to directly use the response from this first Ajax call, because we don't want to use the internal restore
action, then our only choice is to somehow prevent that first Ajax call from happening at all. But since the JavaScript fetch()
function always follows redirects, the only real way to do this is to make Symfony not return a real redirect after a successful form submit.
So here's the idea... it's kind of crazy. In Symfony, we're going to detect if a request is being sent via a turbo-frame
and if that frame has the data-turbo-form-redirect
attribute. If both of these are true and if the Response
from the controller is a redirect, we will change the Response
to... not be a redirect! We'll return a normal 200 status code but store the URL that we want to redirect to as header on the response. Then, we'll prevent Turbo from rendering that response, like we already are, read the URL from the header, navigate with Turbo and voilà! We redirect the page without the duplicate request.
So where do we start? Turbo already adds a Turbo-Frame
header to any Ajax request that happen inside a frame. We can see this, for example, down on the POST request. All the way near the bottom... there it is: turbo-frame: product-info
. We can read that in Symfony.
But what we can't yet read in Symfony is whether or not this frame has the data-turbo-form-redirect
attribute. To make that possible, let's hook into Turbo and add that information as a new request header.
In turbo-helper.js
, we need to listen to another event. Head up to the constructor()
... and say document.
. Actually, cheat. Steal the event listener code from below... and change the event to turbo:before-fetch-request
.
Remember: Turbo dispatches this event right before it makes any Ajax request. Inside, call a new method - this.beforeFetchRequest()
- and pass the event
.
Copy that method name, head down to the methods... and add that with the event
argument. Inside, console.log(event)
so we can see what it looks like.
... lines 1 - 3 | |
const TurboHelper = class { | |
constructor() { | |
... lines 6 - 17 | |
document.addEventListener('turbo:before-fetch-request', (event) => { | |
this.beforeFetchRequest(event); | |
}); | |
... lines 21 - 26 | |
} | |
... lines 28 - 106 | |
beforeFetchRequest(event) { | |
console.log(event); | |
} | |
... lines 111 - 130 | |
} | |
... lines 132 - 134 |
Back at our browser, refresh. This logs every time Turbo makes an Ajax request, like when we navigate... or a frame loads. This is from the weather frame. And I think if we go down to the bottom... yep! It fires again when the second weather frame loads.
Head over to the cart page, clear the console, then add an item to the cart. Ooh, the event triggered three times. One was for the submit, one for the navigation to the next page and the last was for the weather widget that loaded on this page.
Check out the first log, which is from the POST request when we submit the form into the frame. Ah, event.detail
has a fetchOptions
key! This is the collection of options that are about to be passed to the fetch()
function. And it has a headers
key with Turbo-Frame
inside.
That's no surprise... but we can use that in JavaScript to figure out if this frame has the special data-turbo-form-redirect
attribute.
Check it out: say const frameId =
and read that header: event.detail.fetchOptions.headers
... and we're looking Turbo-Frame
. We need to use square brackets instead of .
because the key has a dash in it.
Now, if there is not a frameId
, then this request is not happening inside a frame. In that case, do nothing.
But if we do have a frameId
, we can use that to find this element: const frame = document.querySelector()
... and then use ticks so we can look for #
then ${frameId}
.
Yep, we're literally finding that <turbo-frame>
element on the page! If we can't find the frame for some reason - which shouldn't happen - or if the frame does not have the dataset of turboFormRedirect
, then do nothing. Whoops - make sure that's turboFormRedirect
.
Go back to the cart page and inspect element on the frame. As a reminder, this does have the data-turbo-form-redirect="true"
attribute. That's what we're looking for.
At this point, we know that the request is happening in a frame and that the frame does have the data-turbo-form-redirect
attribute. And so, we're going to add a new header. Use event.detail.fetchOptions.headers
again to invent a new header called, how about, Turbo-Frame-Redirect
. Set it to 1
.
... lines 1 - 107 | |
beforeFetchRequest(event) { | |
const frameId = event.detail.fetchOptions.headers['Turbo-Frame']; | |
if (!frameId) { | |
return; | |
} | |
const frame = document.querySelector(`#${frameId}`); | |
if (!frame || !frame.dataset.turboFormRedirect) { | |
return; | |
} | |
event.detail.fetchOptions.headers['Turbo-Frame-Redirect'] = 1; | |
} | |
... lines 122 - 145 |
Cool! Let's go check it! At your browser, any normal request - even a request inside a frame like for the weather widget - will not have the new header. Check the weather frame request. All the way down... yep! It does have a turbo-frame
header... but not turbo-frame-redirect
.
But now go back to the cart and clear the requests. Submit the form... scroll up to that request... and scroll down. There it is! turbo-frame-redirect
! We can now detect - from Symfony - when a request is going through this type of a frame. Oh yes, we're dangerous.
Next, let's turn to the Symfony side of things where we'll use this header to magically transform redirect responses into something that we can better handle in JavaScript.
Hey Nick F. !
Yes, I recorded this in an early beta version, and there we a bunch of nice little features added after. Not needing to "work around Turbo limitations" is a great thing in general. I'm going to check into this as well and see if we can recommend this solution as a note in the video.
Btw, I fixed your code blocks - Disqus makes them a PAIN - sorry about that!
Thanks!
Hey team!
I was sceptical about stimulus and turbo at first, mostly because I spent a lot of time learning react to give my front end a better user experience, but after playing around with stimulus for a bit, I must admit, I love it, and I can't see myself going back ever again.
But I still have a question: In my app, I want to have a small area at the bottom of the screen that permanently stays when the user navigates. I could wrap the rest of the body in a turbo frame to achieve this, however, this kind of breaks navigation (at least in my case) because the address bar is not updated anymore. Is there a way to get the functionality of a turbo frame, but make it update the address bar without a crazy hack?
Hey Tobias!
Something like this would indeed be a bit hacky. The way I can think of it is, every time your frame navigates to another page, you could manually change the URL by doing something like this:
window.history.pushState({"html":response.html,"pageTitle":response.pageTitle},"", urlPath);
Now to know <i>when</i> a frame changes is another problem! You can wrap a stimulus controller around the frame that looks for mutations to the <i>src</i> attribute. Here's an example of that:
https://gist.github.com/Intrepidd/ac68cb7dfd17d422374807efb6bf2f42
Hey again shadowc !
Just a small update: this is now supported in Turbo 7.1.0 - here are the docs :) https://turbo.hotwired.dev/...
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
}
}
Turbo has had a lot of updates. Here's a much simpler way to get the same functionality using stimulus (I'm not using the global turbo-helper):
Sorry, this text editor kind of screws up the code and changes some characters.
<b>turbo-frame div:</b>
import { Controller } from 'stimulus';
import * as Turbo from "@hotwired/turbo";
export default class extends Controller{
}