Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Prevent a turbo-frame from Rendering

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

As usual, I'm going to complicate things! But I have a good reason: I really want us to get the most out of frames... and we have a bug hiding.

Head over to ProductAdminController. As we just talked about, this redirects to the product_admin_index page. Let's pretend that we want to redirect this to the "reviews" page for the new product. Change this to app_product_reviews and pass the id wildcard set to the new id: $product->getId().

... lines 1 - 31
/**
* @Route("/new", name="product_admin_new", methods={"GET","POST"})
*/
public function new(Request $request): Response
{
... lines 37 - 51
return $this->redirectToRoute('app_product_reviews', [
'id' => $product->getId(),
]);
}
... lines 56 - 107

Cool. But this change won't affect our modal. When the modal submit is successful, we're simply closing the modal, staying on the page and completely ignoring the frame that lives in the now-closed modal. This new redirect would only affect us if we went directly to the /new admin page where the form targets the full page.

So, since this won't affect us, it shouldn't break anything! Famous last words. Refresh, open the modal, add some details and submit. Oh! The modal did close... but we have an error in the console!

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

Ah, the problem is that, even though we closed the modal, the turbo-frame still followed the redirect to the product review page. Then, like it always does, it looked for a <turbo-frame> with id="product-info"... which that page doesn't have.

So what we really want to do is just... close the modal and tell turbo to not follow the redirect. Unfortunately, the turbo:submit-end event is too late to tell Turbo to do that!

We could ignore this error... or hack an empty turbo-frame onto the reviews page... but let's fix this properly. It's a good challenge.

Order of Turbo Events

When we submit this form, four events are triggered in this order: turbo:before-fetch-request, turbo:submit-start, turbo:before-fetch-response and finally turbo:submit-end. Then the frame is rendered.

But, wait a second. If the frame isn't rendered until after turbo:submit-end, why is it too late to tell Turbo to not render the frame? The truth is that turbo:submit-end isn't actually too late. The real problem is that Turbo doesn't give us a way to cancel rendering from this event. But it does give us this power from the event right before this: turbo:before-fetch-response.

turbo:before-fetch-response

This event is triggered right after the Ajax call finishes, actually after both Ajax calls have finished: the form submit POST and the second request to the redirected page. But at this point, the frame has not been re-rendered.

Tip

Starting in Turbo 7 RC4 (and so also in the stable Turbo 7), the turbo:before-fetch-response event is now triggered from whatever element triggered the Ajax call. This means that you can now use this.element.addEventListener instead of attaching it to document. Nice!

This time, we do need to attach the event to document because this event is dispatched directly there - not on the form. For now, I'm going to not hide the modal.

... lines 1 - 3
export default class extends Controller {
... lines 5 - 7
connect() {
document.addEventListener('turbo:before-fetch-response', (event) => {
console.log(event);
if (event.detail.success) {
//this.modal.hide();
}
});
}
... lines 16 - 20
}

Refresh, open the modal and fill out the form so we can see what the event looks like for a successful form submit. Cool. In the console, we see two of these events. The first happened when we opened the modal: that's the GET request to load the form. The second is from the form submit.

Open this up and look at the detail property: it has a fetchResponse object and inside of it that... awesome! A succeeded key and a redirected key! So it tells us if the request was successful and also if it was redirected.

So here's the plan: when this event happens, if a modal is open and the Ajax call was successful and the Ajax call was a redirect, we'll assume that a form was just submitted and hide the modal.

Back in the listener function, delete the code. Then, if not this.modal - so if the modal has never opened - or if not this.modal._isShown - an internal way to detect whether a modal is visible - then we don't need to do anything. Just return.

But if the modal is open, set const fetchResponse to event.detail.fetchResponse: that's the object we were just looking at. If fetchResponse.succeeded and fetchResponse.redirected, then we're going to assume this was a successful form submit and hide the modal.

Cancelling the Frame Render

If we stopped now, this would do the exact same thing as before... just with more code. It would hide the modal... but then the frame would still try to render and give us that annoying error. But there's a key difference between this event and turbo:submit-end: this event is cancellable. In this event we're allowed to say event.preventDefault().

... lines 1 - 3
export default class extends Controller {
... lines 5 - 7
connect() {
document.addEventListener('turbo:before-fetch-response', (event) => {
console.log(event);
if (!this.modal || !this.modal._isShown) {
return;
}
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
event.preventDefault();
this.modal.hide();
}
});
}
... lines 22 - 26
}

Normally, we use event.preventDefault() to prevent form submits or link clicks. Some custom events - like this one - also allow you to call this method... and it could mean anything based on the event. In this case, it communicates to Turbo that we would like to prevent this response from rendering.

Let's try it. Refresh, open, fill out the form and submit. Yes! The modal closed... this time with no error!

We're amazing! Oh, except... hmm... this still isn't quite what we want. The modal closed... but the page didn't reload or refresh... so we don't see the new product in the list immediately. Let's fix that next and finish our Turbo-powered modal system.

Leave a comment!

2
Login or Register to join the conversation
Nick-F Avatar

I guess it must have changed since this video, but turbo:before-fetch-request now fires on whatever element that triggers it, not on document.
Here's the docs:


turbo:before-fetch-request fires before Turbo issues a network request to fetch the page. Access the requested location with event.detail.url and the fetch options object with event.detail.fetchOptions. This event fires on the respective element (turbo-frame or form element) which triggers it and can be accessed with event.target property.
Reply

Ah! You're right! It's new in RC4! https://github.com/hotwired...

So that will allow us to simplify things. I'll investigate and add a note to the video to help people.

Thanks for the pointer!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

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

What JavaScript libraries does this tutorial use?

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