feat: reload the page with a flash when an htmx request fails

This commit is contained in:
fromme committed 2026-09-17 16:48:48 +02:00
1 parent 0be3c2ccde
commit a2e4035889
14 files changed
+205 -210

No files matched your search

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Leaves a flash behind when an htmx request fails, for the page reload that follows.
*
* htmx is only used for modals and out-of-band refreshes, so there is always a whole page under a
* failed request -- and every page is a GET. assets/app.js therefore answers a failed htmx request
* by reloading that page, and this flash is what tells the user why it just reloaded.
*
* It only adds the flash. The response is left alone, so it keeps its real status and Symfony
* logs the exception as usual. A listener that already produced a response stops propagation
* before this runs -- notably the firewall's entry point, which sends an expired session to the
* login page with HX-Redirect instead.
*/
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
final class HtmxErrorFlashListener
{
public function __invoke(ExceptionEvent $event): void
{
$request = $event->getRequest();
if (!$request->headers->has('HX-Request') || !$request->hasSession()) {
return;
}
$session = $request->getSession();
if ($session instanceof FlashBagAwareSessionInterface) {
$session->getFlashBag()->add('error', 'Da ist etwas schiefgelaufen. Bitte versuche es erneut.');
}
}
}
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
/**
* Renders an error modal when an htmx request blows up.
*
* htmx does not swap a 5xx by default, so an uncaught exception on a fragment request produces
* no visible effect whatsoever -- the user clicks and nothing happens. This turns it into
* something they can see: a 200 carrying the error modal, retargeted at <body> so it appends
* regardless of what the original hx-target was.
*
* allowCustomResponseCode() is what keeps the 200; without it the kernel rewrites it to 500 and
* htmx discards the body again.
*/
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
final readonly class HtmxExceptionListener
{
public function __construct(private Environment $twig)
{
}
public function __invoke(ExceptionEvent $event): void
{
if (!$event->getRequest()->headers->has('HX-Request')) {
return;
}
if ($event->hasResponse()) {
return;
}
$response = new Response($this->twig->render('htmx_error_modal.html.twig'), Response::HTTP_OK);
$response->headers->set('HX-Retarget', 'body');
$response->headers->set('HX-Reswap', 'beforeend');
$event->setResponse($response);
$event->allowCustomResponseCode();
}
}
+12 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Security;
use App\Htmx\HxRedirectResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -16,6 +17,10 @@ use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface
*
* Deliberately the login *page* rather than straight into /auth/init: the page is where a failed
* login's flash message is shown, and bouncing there instead would swallow it.
*
* An htmx request -- typically a click after the session expired -- gets HX-Redirect instead:
* a plain 302 is followed invisibly by the browser, and htmx would swap the whole login page into
* whatever the click targeted, a modal included.
*/
final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface
{
@@ -25,6 +30,12 @@ final readonly class LoginEntryPoint implements AuthenticationEntryPointInterfac
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return new RedirectResponse($this->urlGenerator->generate('app_login'));
$url = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new HxRedirectResponse($url);
}
return new RedirectResponse($url);
}
}