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 SubscribeSo... we've crushed the Bootstrap modal problem! But we have the same issue in one other spot. Go back to the homepage, add an item to your cart and go to the cart. Try to remove the item. This cute little dialog is powered by a library called Sweetalert. Once again, if we click back and then forward, it pops up again, which might be ok... if it actually worked! But... it doesn't... because all of its event listeners are gone.
Okay: let's try using Sweetalert's close functionality to tell it to close before the page is snapshotted. To do that, import Swal from 'sweetalert2'
.
Then, down inside of the function, if Swal.isVisible()
- they have a nice function to check if Sweetalert is visible - then Swal.close()
.
... lines 1 - 13 | |
import { Modal } from 'bootstrap'; | |
import Swal from 'sweetalert2'; | |
document.addEventListener('turbo:before-cache', () => { | |
if (document.body.classList.contains('modal-open')) { | |
const modalEl = document.querySelector('.modal'); | |
const modal = Modal.getInstance(modalEl); | |
modalEl.classList.remove('fade'); | |
modal._backdrop._config.isAnimated = false; | |
modal.hide(); | |
modal.dispose(); | |
} | |
if (Swal.isVisible()) { | |
Swal.close(); | |
} | |
}); |
It's that simple! Or at least... it might be. Let's go try this. Refresh the cart page, hit remove, go back, go forward and... it worked! Wait, I can't scroll... and nothing is clickable! Inspect element anywhere. Uh oh: a Sweetalert backdrop element is still there! It's invisible, but it's blocking the page!
This is the exact same problem we just saw with Bootstrap's modal: the close animation never finishes, so cleanup never happens. The solution is to, once again, tell Sweetalert to close... but without an animation.
This is easier than Bootstrap... but it still tool some digging to figure out how to do it. In this case, right before we close, we can say Swal.getPopup()
- which gives you the Element
associated with the dialog - .style.animationDuration = 0ms
.
... lines 1 - 13 | |
import { Modal } from 'bootstrap'; | |
import Swal from 'sweetalert2'; | |
document.addEventListener('turbo:before-cache', () => { | |
if (document.body.classList.contains('modal-open')) { | |
const modalEl = document.querySelector('.modal'); | |
const modal = Modal.getInstance(modalEl); | |
modalEl.classList.remove('fade'); | |
modal._backdrop._config.isAnimated = false; | |
modal.hide(); | |
modal.dispose(); | |
} | |
if (Swal.isVisible()) { | |
Swal.getPopup().style.animationDuration = '0ms' | |
Swal.close(); | |
} | |
}); |
How could I possibly know that this is the code we need? If you look internally at Sweetalert, you'll notice that it looks at its popup element and checks to see if the popup element has an animationDuration
declared on its style. If it does, then it waits for the animation to finish before cleaning up. By changing the animationDuration
to zero, Sweetalert will now see that it does not need to wait... and will clean up everything immediately.
Let's try it! Refresh, click remove, click back and click forward. Everything looks fine! When I hover over the checkout button, it is not being blocked by a backdrop and I can click "remove" again. All good!
One tiny problem with this approach is that both Bootstrap's modal and the sweetalert2
library will now be downloaded on every page since we're importing them from our main app.js
file.
You might not care... and you probably shouldn't care... at least not until you investigate optimizing your CSS and JS file sizes later.
But, this is interesting. Sweetalert is only used on this one page. So, it's kind of wasteful to force the user to download it on every page load... even though they will rarely need it.
Open assets/controllers/submit-confirm_controller.js
. This is the controller that handles the Sweetalert confirmation on this page. Notice that it has stimulusFetch: lazy
above it.
... lines 1 - 4 | |
/* stimulusFetch: 'lazy' */ | |
export default class extends Controller { | |
static values = { | |
title: String, | |
text: String, | |
icon: String, | |
confirmButtonText: String, | |
submitAsync: Boolean, | |
} | |
... lines 14 - 54 |
This is something that we added in our Stimulus tutorial. Thanks to this, before we started adding all of this new code in app.js
- so pretend this isn't there - the sweetalert2
JavaScript was not downloaded on every page. It was only downloaded when an element that uses this controller first appeared on the page... which is pretty cool! The code for this controller & its dependencies literally waits until its needed and then downloads itself.
But now that we're importing sweetalert2
directly in app.js
, it is being downloaded on every page. If you care enough about this, you can fix it using a very specific Webpack trick. It's a little nuts actually. I'll paste in the first half of the code, indent, then close things.
... lines 1 - 25 | |
// internal way to see if sweetalert2 has been imported yet | |
if (__webpack_modules__[require.resolveWeak('sweetalert2')]) { | |
// because we know it's been imported, this will run synchronously | |
import('sweetalert2').then((Swal) => { | |
if (Swal.default.isVisible()) { | |
Swal.default.getPopup().style.animationDuration = '0ms' | |
Swal.default.close(); | |
} | |
}) | |
} | |
... lines 36 - 37 |
Let's walk through this. The __webpack_modules__
thing is an internal way - along with require.resolveWeak
- to check to see if sweetalert2
has already been downloaded and is available. But it does this without causing it to become packaged with app.js
. If it has already been downloaded, then we can use this import to grab it. Because we know it's already available, this executes instantly. Then, we run our normal code down here. The only thing we need to change - due to the way that the import()
function works - is that every Swal
needs a .default
to access that module's default
export.
If this isn't making much sense to you, don't worry. This is a complex performance optimization. I thought I'd mention it for the performance and Webpack geeks out there.
Oh, and before we try this, scroll up and remove the now-unused import.
To see the result of this, go back to the homepage and then do a full page refresh. Over on the network tools, view the JS tab. It's not super obvious yet, but if you look closely at the names here... you won't see any that mention sweetalert2. It has not been downloaded yet.
Let me clear this and let's watch what happens when we click to the cart page. Yes! Check it out. One of the files that was downloaded - this one - has sweetalert2
in the middle of its name! That contains Sweetalert and proves that it wasn't downloaded until it was actually needed... even though we have some code in app.js
that takes advantage of it.
So now that we've tackled some of the most annoying problems with Turbo, which is cleaning up the snapshot, let's organize all of the new event code to make room for more turbo event listeners later. That will put us in a great position to discuss the last tricky thing with Turbo drive: handling third-party JavaScript like JavaScript widgets and analytics code.
Hey Eddie,
Hm, good question! This might work too at the first sight, and probably is much simpler, but I'm not sure if there're any pitfalls with this approach. I think you can give it try to see in action. I would be curios about your results if you could share any! :)
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
}
}
About the backdrops - isn't it better just to element.remove()?