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

+8
View File
@@ -23,3 +23,11 @@ htmx.config.responseHandling = [
{ code: '422', swap: true, error: false },
{ code: '[45]..', swap: false, error: true },
];
// Anything else that fails -- a 4xx other than 422, a 5xx, or no response at all -- reloads the
// page. htmx only opens modals and refreshes fragments, so there is always a whole page underneath,
// and every page is a GET: reloading is safe, closes the modal and shows current data. The server
// keeps honest status codes and leaves a flash explaining the reload (HtmxErrorFlashListener).
const reloadPage = () => window.location.reload();
document.addEventListener('htmx:responseError', reloadPage);
document.addEventListener('htmx:sendError', reloadPage);
+2 -1
View File
@@ -10,7 +10,8 @@ security:
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets)/
# _error is the dev-only preview of the error pages (/_error/404, /_error/500).
pattern: ^/(_profiler|_wdt|_error|assets)/
security: false
main:
lazy: true
@@ -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);
}
}
@@ -0,0 +1,21 @@
{#
Symfony's error page for anything but a 404, adopted from myep-team. Only used with debug off;
dev keeps showing the exception page (preview at /_error/500).
Never reached by an htmx request: a failed one reloads its page instead (see assets/app.js).
#}
{% extends 'layout.html.twig' %}
{% block title %}Fehler · ephub{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
Oops... Ein Fehler ist aufgetreten
</h1>
<p class="mb-8">
Wir haben uns ein paar Notizen dazu gemacht und werden das Problem so schnell wie möglich beseitigen.
</p>
<a class="button button--primary" href="{{ path('app_dashboard') }}">
nochmal von vorne
</a>
{% endblock %}
@@ -0,0 +1,16 @@
{# Symfony's 404 page, adopted from myep-team. Only used with debug off. #}
{% extends 'layout.html.twig' %}
{% block title %}Nicht gefunden · ephub{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
Oops... Ein Fehler ist aufgetreten
</h1>
<p class="mb-8">
Die von dir aufgerufene Seite können wir leider nicht (mehr) finden.
</p>
<a class="button button--primary" href="{{ path('app_dashboard') }}">
nochmal von vorne
</a>
{% endblock %}
-9
View File
@@ -1,9 +0,0 @@
{% embed 'htmx_modal.html.twig' %}
{% block title %}Oops…{% endblock %}
{% block content %}
<div class="bg-red-600 text-gray-100 rounded px-4 py-3 text-sm">
Da ist etwas schiefgelaufen. Wir haben uns Notizen gemacht und kümmern uns
schnellstmöglich darum.
</div>
{% endblock %}
{% endembed %}
@@ -43,8 +43,7 @@ class ResourceAdminControllerTest extends WebTestCase
$this->client->request('GET', '/admin/resources');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
// Not as htmx: an htmx request gets the error modal instead of the bare status.
$this->client->request('GET', '/admin/resources/new/desk');
$this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
@@ -123,7 +122,7 @@ class ResourceAdminControllerTest extends WebTestCase
{
$this->client->loginUser($this->admin());
$this->client->request('GET', '/admin/resources/new/boat');
$this->client->request('GET', '/admin/resources/new/boat', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
@@ -221,7 +220,7 @@ class ResourceAdminControllerTest extends WebTestCase
$crawler = $this->client->request('GET', '/admin/resources');
self::assertCount(0, $crawler->filter(\sprintf('[hx-get="/admin/resource-groups/%d/delete"]', $room->getId())));
$this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever']);
$this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
+1 -8
View File
@@ -155,12 +155,6 @@ class BookingControllerTest extends WebTestCase
self::assertNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]));
}
/**
* Note the status is 200, not 403: HtmxExceptionListener deliberately converts every
* exception on an htmx request -- access denied included -- into a visible error modal,
* because htmx discards a 4xx body and the click would otherwise do nothing at all.
* What matters here is that the booking survives.
*/
public function testReleasingAnotherPersonsBookingIsRefused(): void
{
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
@@ -170,8 +164,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse();
self::assertSame('body', $response->headers->get('HX-Retarget'));
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertNotNull($this->em->getRepository(Booking::class)->find($booking->getId()));
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* A failed htmx request keeps its status, and the page reload that app.js answers it with shows
* why it happened.
*/
class HtmxErrorFlashListenerTest extends WebTestCase
{
private const MESSAGE = 'Da ist etwas schiefgelaufen.';
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = self::createClient();
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$user = new User('[email protected]', 'Anna');
$user->setRoles(['ROLE_EMPLOYEE']);
$this->em->persist($user);
$this->em->flush();
$this->client->loginUser($user);
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAFailedHtmxRequestKeepsItsStatusAndLeavesAFlash(): void
{
$this->client->request('GET', '/admin/resources', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertFalse($this->client->getResponse()->headers->has('HX-Retarget'));
$this->client->request('GET', '/');
self::assertSelectorTextContains('#flashes', self::MESSAGE);
}
/**
* An ordinary page request shows Symfony's error page instead, so a flash would only turn up
* later, out of context.
*/
public function testAnOrdinaryRequestLeavesNoFlash(): void
{
$this->client->request('GET', '/admin/resources');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->request('GET', '/');
self::assertSelectorTextNotContains('#flashes', self::MESSAGE);
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Drives the listener through the real kernel, which is the only way to prove
* allowCustomResponseCode() actually holds the 200 -- the kernel rewrites it otherwise, and a
* unit test on the event object cannot see that happen.
*
* An unrouted path is used as the exception source so no throwing fixture controller is needed.
*/
class HtmxExceptionListenerFunctionalTest extends WebTestCase
{
public function testAnExceptionOnAnHtmxRequestArrivesAsA200ErrorModal(): void
{
$client = self::createClient();
$client->catchExceptions(true);
$client->request('GET', '/does-not-exist', server: ['HTTP_HX-Request' => 'true']);
$response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('body', $response->headers->get('HX-Retarget'));
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
}
public function testTheSameExceptionOnAnOrdinaryRequestStillGivesAnHonestErrorStatus(): void
{
$client = self::createClient();
$client->catchExceptions(true);
$client->request('GET', '/does-not-exist');
// Asserting on headers rather than body: in debug mode Symfony's exception page renders
// the source of every stack frame, this file included, so any marker string searched for
// here would match its own source code.
self::assertSame(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode());
self::assertFalse($client->getResponse()->headers->has('HX-Retarget'));
self::assertFalse($client->getResponse()->headers->has('HX-Reswap'));
}
}
@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\EventListener\HtmxExceptionListener;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Twig\Environment;
/**
* Uses the real Twig so the error modal template is exercised too -- an unrenderable modal here
* would throw from inside exception handling, which is the worst possible place for it.
*/
class HtmxExceptionListenerTest extends KernelTestCase
{
public function testHtmxRequestsGetTheErrorModalAsA200(): void
{
$event = $this->dispatch(['HTTP_HX-Request' => 'true']);
$response = $event->getResponse();
self::assertInstanceOf(Response::class, $response);
// 200, not 500: htmx does not swap 5xx, so an error response would be discarded unseen.
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
// Without this the kernel turns the 200 back into a 500.
self::assertTrue($event->isAllowingCustomResponseCode());
}
public function testTheModalIsRetargetedAtTheBodySoItAppearsWhateverTheTriggerWas(): void
{
$response = $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse();
self::assertNotNull($response);
self::assertSame('body', $response->headers->get('HX-Retarget'));
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
}
public function testTheRenderedModalIsTheStandaloneModalFragment(): void
{
$content = (string) $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse()?->getContent();
self::assertStringContainsString('id="htmx-modal"', $content);
self::assertStringContainsString('data-controller="modal"', $content);
self::assertStringNotContainsString('<html', $content);
}
public function testOrdinaryRequestsAreLeftToSymfonysErrorHandling(): void
{
self::assertNull($this->dispatch()->getResponse());
}
public function testAResponseSetByAnEarlierListenerIsNotOverwritten(): void
{
$event = $this->event(['HTTP_HX-Request' => 'true']);
$event->setResponse($alreadySet = new Response('handled elsewhere'));
($this->listener())($event);
self::assertSame($alreadySet, $event->getResponse());
}
/**
* @param array<string, string> $server
*/
private function dispatch(array $server = []): ExceptionEvent
{
$event = $this->event($server);
($this->listener())($event);
return $event;
}
/**
* @param array<string, string> $server
*/
private function event(array $server): ExceptionEvent
{
return new ExceptionEvent(
self::bootKernel(),
Request::create('/bookings', server: $server),
HttpKernelInterface::MAIN_REQUEST,
new \RuntimeException('boom'),
);
}
private function listener(): HtmxExceptionListener
{
return new HtmxExceptionListener(self::getContainer()->get(Environment::class));
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class LoginEntryPointTest extends WebTestCase
{
/**
* A plain 302 would be followed invisibly and the login page swapped into the click's target.
*
* The status stays 403 -- Symfony's firewall keeps the access-denied status on this path --
* and that is fine: htmx 2.0.10 acts on HX-Redirect before it looks at the status at all
* (handleAjaxResponse), so it navigates rather than raising htmx:responseError.
*/
public function testAnExpiredSessionOnAnHtmxRequestNavigatesToTheLoginPage(): void
{
$client = self::createClient();
$client->request('GET', '/book/desk', server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/login', $client->getResponse()->headers->get('HX-Redirect'));
}
public function testAnExpiredSessionOnAnOrdinaryRequestStillRedirects(): void
{
$client = self::createClient();
$client->request('GET', '/book/desk');
self::assertResponseRedirects('/login');
}
}